@bachi/pi-coder 1.0.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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/AGENTS.md +100 -0
- package/config/pi-statusline.json +140 -0
- package/config/settings.json +38 -0
- package/config/web-search.json +5 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +123 -0
- package/docs/development.md +177 -0
- package/docs/extensions.md +292 -0
- package/docs/handbook.zh.md +432 -0
- package/docs/installation.md +124 -0
- package/docs/themes.md +107 -0
- package/extensions/ask-user-question/answers.test.ts +104 -0
- package/extensions/ask-user-question/answers.ts +72 -0
- package/extensions/ask-user-question/dialog.test.ts +180 -0
- package/extensions/ask-user-question/dialog.ts +102 -0
- package/extensions/ask-user-question/index.ts +253 -0
- package/extensions/ask-user-question/model.test.ts +275 -0
- package/extensions/ask-user-question/model.ts +259 -0
- package/extensions/ask-user-question/schema.ts +49 -0
- package/extensions/ask-user-question/types.ts +86 -0
- package/extensions/ask-user-question/validate.test.ts +183 -0
- package/extensions/ask-user-question/validate.ts +110 -0
- package/extensions/ask-user-question/view.ts +262 -0
- package/extensions/auto-default-model/default-model.test.ts +268 -0
- package/extensions/auto-default-model/index.ts +87 -0
- package/extensions/bash-command-collapse.ts +1476 -0
- package/extensions/below-editor-after-statusline.ts +118 -0
- package/extensions/clear-command.ts +29 -0
- package/extensions/cwd-statusline.ts +39 -0
- package/extensions/exit-command.ts +59 -0
- package/extensions/fenceless-code-block/index.test.ts +208 -0
- package/extensions/fenceless-code-block/index.ts +28 -0
- package/extensions/fenceless-code-block/render.test.ts +177 -0
- package/extensions/fenceless-code-block/render.ts +142 -0
- package/extensions/folder-history.ts +197 -0
- package/extensions/init-command.ts +163 -0
- package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
- package/extensions/prompt-editor/bash-prompt.ts +59 -0
- package/extensions/prompt-editor/render.test.ts +283 -0
- package/extensions/prompt-editor.ts +212 -0
- package/extensions/read-path-collapse.ts +474 -0
- package/extensions/recap/index.test.ts +348 -0
- package/extensions/recap/index.ts +462 -0
- package/extensions/recap/subagents.test.ts +144 -0
- package/extensions/recap/subagents.ts +128 -0
- package/extensions/rewind/README.md +229 -0
- package/extensions/rewind/checkpoints.test.ts +560 -0
- package/extensions/rewind/checkpoints.ts +820 -0
- package/extensions/rewind/flow.test.ts +756 -0
- package/extensions/rewind/flow.ts +362 -0
- package/extensions/rewind/index.ts +400 -0
- package/extensions/rewind/picker.ts +135 -0
- package/extensions/rewind/viewport.test.ts +76 -0
- package/extensions/rewind/viewport.ts +48 -0
- package/extensions/simple-task/gap.test.ts +147 -0
- package/extensions/simple-task/gap.ts +122 -0
- package/extensions/simple-task/index.ts +439 -0
- package/extensions/simple-task/types.ts +53 -0
- package/extensions/simple-task/widget.ts +86 -0
- package/extensions/startup-logo/header-guard.test.ts +274 -0
- package/extensions/startup-logo/header-guard.ts +166 -0
- package/extensions/startup-logo/index.test.ts +305 -0
- package/extensions/startup-logo/index.ts +194 -0
- package/extensions/startup-logo/loaded-sections.test.ts +257 -0
- package/extensions/startup-logo/loaded-sections.ts +267 -0
- package/extensions/startup-logo/logo.test.ts +124 -0
- package/extensions/startup-logo/logo.ts +124 -0
- package/extensions/statusline/footer-guard.test.ts +273 -0
- package/extensions/statusline/footer-guard.ts +171 -0
- package/extensions/statusline/git.test.ts +174 -0
- package/extensions/statusline/git.ts +142 -0
- package/extensions/statusline/index.ts +294 -0
- package/extensions/statusline/line.test.ts +316 -0
- package/extensions/statusline/line.ts +201 -0
- package/extensions/subagent-log-guard/filter.test.ts +85 -0
- package/extensions/subagent-log-guard/filter.ts +32 -0
- package/extensions/subagent-log-guard/index.ts +112 -0
- package/extensions/theme-command.ts +263 -0
- package/extensions/thinking-collapse/window.test.ts +321 -0
- package/extensions/thinking-collapse/window.ts +354 -0
- package/extensions/thinking-collapse.ts +60 -0
- package/extensions/tool-diff/title-row.test.ts +254 -0
- package/extensions/tool-diff/title-row.ts +191 -0
- package/extensions/tool-diff.ts +1276 -0
- package/extensions/working-indicator/bash-spinner.test.ts +135 -0
- package/extensions/working-indicator/bash-spinner.ts +114 -0
- package/extensions/working-indicator/index.test.ts +579 -0
- package/extensions/working-indicator/index.ts +940 -0
- package/extensions/working-indicator/spinner-frames.test.ts +219 -0
- package/extensions/working-indicator/spinner-frames.ts +156 -0
- package/extensions/working-indicator/summary-request.test.ts +195 -0
- package/extensions/working-indicator/summary-request.ts +207 -0
- package/extensions/working-indicator/working-summary.test.ts +499 -0
- package/extensions/working-indicator/working-summary.ts +375 -0
- package/package.json +71 -0
- package/themes/ayu.json +97 -0
- package/themes/catppuccin.json +103 -0
- package/themes/summer-night.json +87 -0
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-diff — edit / write 工具的 Claude Code 风格 diff 渲染:
|
|
3
|
+
* 整行底色(含行号槽,见 `layoutRows`)+ 行号槽 + 行内高亮 + 语法高亮。
|
|
4
|
+
*
|
|
5
|
+
* 为什么必须用扩展、主题方案做不到:
|
|
6
|
+
* pi 的 diff 配色只有三个**前景色** token(`toolDiffAdded` / `toolDiffRemoved` /
|
|
7
|
+
* `toolDiffContext`,见 theme.d.ts 的 ThemeColor),而 `Theme.bg()` 只认 7 个 ThemeBg
|
|
8
|
+
* (selectedBg / searchMatchBg / userMessageBg / customMessageBg / toolPendingBg /
|
|
9
|
+
* toolSuccessBg / toolErrorBg),**里面没有任何 diff 相关的底色**。所以主题最多只能把
|
|
10
|
+
* +/- 行染成不同前景色,做不出 CC 那种"整行绿底 / 整行红底 + 行内更亮底色"的效果。
|
|
11
|
+
* 扩展自己拼 ANSI 才能逐行上底色。底色本身仍然**取自皮肤**:皮肤 `colors` 里的自定义
|
|
12
|
+
* key(`toolDiffAddedBg` / `toolDiffRemovedBg`)pi 照样解析进 Theme,扩展按 key 读出来
|
|
13
|
+
* 再把前景色 SGR 换成底色 SGR —— 换皮肤时底色跟着换,见 `DIFF_ADDED_BG_TOKEN`。
|
|
14
|
+
*
|
|
15
|
+
* 为什么必须 `renderShell: "self"`:
|
|
16
|
+
* 默认 shell 会把整个工具输出包进一个 Box 并**整块**染色 —— tool-execution.js 的
|
|
17
|
+
* `updateDisplay()` 里 bgFn 按状态选 `toolPendingBg` / `toolErrorBg` / `toolSuccessBg`。
|
|
18
|
+
* 于是逐行的红底会落在一个绿盒子里面,观感错乱。设成 `"self"` 后走
|
|
19
|
+
* `selfRenderContainer`(tool-execution.js:180 分支),它是个纯 Container、
|
|
20
|
+
* `renderContainer instanceof Box` 为 false 所以 bgFn 不会被套用,框体完全自绘。
|
|
21
|
+
* self 模式下 renderCall 与 renderResult 两个组件都装进同一个 selfRenderContainer
|
|
22
|
+
* (updateDisplay() 里),所以标题行不会丢。
|
|
23
|
+
*
|
|
24
|
+
* 执行逻辑一律委托内置实现,只换渲染:
|
|
25
|
+
* `createEditTool(cwd)` / `createWriteTool(cwd)` 都是包根导出的,官方示例
|
|
26
|
+
* examples/extensions/built-in-tool-renderer.ts 就是这个模式。渲染器继承是**按 slot**
|
|
27
|
+
* 合并的(renderers/index.js `withBuiltInRenderers`:
|
|
28
|
+
* `renderCall: definition.renderCall ?? builtIn.renderCall`)。两个工具都**自己写
|
|
29
|
+
* renderCall**、只留一行标题:内置 edit 的 renderCall 会渲染一份基于 args 的 diff 预览
|
|
30
|
+
* (`getEditCallRenderComponent`),内置 write 的会把整个 `content` 带语法高亮地铺出来,
|
|
31
|
+
* 执行完后 renderResult 又画一张卡片 —— 同一份改动会显示两遍。
|
|
32
|
+
* (早期版本刻意省略 edit 的 renderCall 以继承那份流式预览,正是因为重复才改掉的。)
|
|
33
|
+
* 注册时用 `{ ...originalTool }` 展开而不是逐字段抄:实测 edit 带 `prepareArguments`
|
|
34
|
+
* (write 没有),两者都没有 `promptSnippet` / `promptGuidelines`(都是 undefined,
|
|
35
|
+
* 所以文档里"提示词元数据不继承"那条警告在这两个工具上不适用)。展开能顺带保住
|
|
36
|
+
* `executionMode` / `constrainedSampling` 等未来新增字段 —— 手抄字段名最容易漏掉这些,
|
|
37
|
+
* 而 `executionMode` 一旦丢掉就可能让 edit 并发执行、造成文件竞态。
|
|
38
|
+
*
|
|
39
|
+
* 标题行的流式形态(与底部 spinner 分工):
|
|
40
|
+
* 流式中是 `Edit <path> ●` / `Write <path> ●` —— 闪烁的 `●` 表示「正在接收字节流」。
|
|
41
|
+
* 工具名首字母大写(`Edit` / `Write`)而不是 pi 内置的小写 `edit` / `write`:本扩展仿的是
|
|
42
|
+
* Claude Code 的观感,那边的工具标题就是大写的;注册名不受影响,只是显示形态。
|
|
43
|
+
* token 数不在标题行滚动显示:底部 spinner 已经在数当前段的产出量
|
|
44
|
+
* (working-indicator/ 的 `↓ N tokens`),标题行再数一遍是重复信息。
|
|
45
|
+
* 参数完成后 `●` 消失,恢复完整形态 `<path> (+N -M)`(新增绿 / 删除红,为 0 的一侧省略)。
|
|
46
|
+
* 行数统计**只在标题行出现一次**:结果卡片里原本那行 `Added N lines, removed M lines`
|
|
47
|
+
* 已经删掉,同一份改动不再报两遍。闪烁的驱动与三处停表见 `startBlink`。
|
|
48
|
+
* 结果已到但 `argsComplete` 恒为 false 的行(中断的行、恢复历史会话渲染出来的行)
|
|
49
|
+
* 靠 `isPartial` 识别,既不闪也不画标记 —— 否则整个历史区每行都会挂一个静态的 `●`;
|
|
50
|
+
* 那种行照样算 `+N -M`(参数在会话日志里是完整的),否则历史区就彻底没有行数信息了。
|
|
51
|
+
*
|
|
52
|
+
* 标题行的路径**从头部**截断(`title-row.ts` 的 `truncateStartToWidth`):
|
|
53
|
+
* 超宽时保留路径尾部、把前面缩成一个 `…`(`Edit …ions/rewind/checkpoints.ts (+3 -5)`),
|
|
54
|
+
* 而不是把行尾连同 `(+N -M)` 一起截掉 —— 文件名和改动量才是这一行要传达的信息,
|
|
55
|
+
* 前面的目录基本是 cwd 的重复。尾部(`+N -M` / `●`)因此永远完整可见:可用宽度先扣掉
|
|
56
|
+
* 工具名和尾部,剩下的才留给路径。
|
|
57
|
+
*
|
|
58
|
+
* 尾部宽度必须**每帧一样**,否则路径会跟着抖:闪烁标记灭态用等宽空格占位(不能返回空串,
|
|
59
|
+
* 否则预算每 500ms 跳 2 列、超长路径的截断窗口跟着左右挪,看上去是「标记在闪、文件名
|
|
60
|
+
* 也在闪」,实测过);`+N -M` 里的五位数缩成 `k`(`(+10043 -11133)` → `(+10k -11.1k)`),
|
|
61
|
+
* 免得两个大数把路径挤没。排版本身在 `tool-diff/title-row.ts`(纯逻辑、不 import pi,
|
|
62
|
+
* `node --test` 可直跑),本文件只负责把 pi-tui 的量度函数与主题接上去。
|
|
63
|
+
*
|
|
64
|
+
* `+N -M` 的来源(renderCall 阶段,工具还没执行,拿不到结果里的 patch):
|
|
65
|
+
* edit 逐条 `edits` 算 `generateUnifiedPatch(oldText, newText)` 再求和 —— 各处改动互不重叠,
|
|
66
|
+
* 求和就等于整文件 patch 的 +/- 行数;write 用执行前抓到的旧内容对新内容算同一套。
|
|
67
|
+
* 结果缓存在行级 `state.stats`(`null` = 算过但算不出来),免得每次重绘都重跑一遍 diff。
|
|
68
|
+
* pi 另有 `computeEditsDiff(path, edits, cwd)` 能给权威数字,但它是 async 的,而 renderCall
|
|
69
|
+
* 是同步渲染入口等不了 —— 所以走同步的逐条求和。
|
|
70
|
+
*
|
|
71
|
+
* 零新增 npm 依赖(与仓库里其它 pi 扩展一致,单文件手动 cp 安装):
|
|
72
|
+
* - edit 的 diff 数据直接用 pi 给的 `EditToolDetails.patch`(标准 unified patch),
|
|
73
|
+
* 不用 structuredPatch。
|
|
74
|
+
* - write 工具的 `TDetails` 是 `undefined`(`createWriteToolDefinition` 的签名),
|
|
75
|
+
* 拿不到 diff。所以在 renderCall 阶段读旧内容(以 path 为键、逐帧重试,见 `WriteState`),
|
|
76
|
+
* 再用包根导出的 `generateUnifiedPatch(path, old, new)` 算**真** diff ——
|
|
77
|
+
* 覆盖已有文件时不会把整个文件都算成"新增"。
|
|
78
|
+
* - 语法高亮用 pi 自己的 `highlightCode` / `getLanguageFromPath`,不引 @shikijs/cli。
|
|
79
|
+
* 实测 `highlightCode(code, lang)` 返回的行数与输入行数一致,且每行用 `\x1b[39m`
|
|
80
|
+
* 收尾(不是 `\x1b[0m`),这对叠加底色很关键。
|
|
81
|
+
* - 行内 diff 自己实现 LCS,不引 diff 包。
|
|
82
|
+
*
|
|
83
|
+
* 两处容易踩的坑(都已在代码里处理):
|
|
84
|
+
* 1. **字符下标 ≠ 终端列宽**:行内高亮的 ranges 是 `computeWordRanges` 算出的 UTF-16 字符偏移,
|
|
85
|
+
* 所以 `injectBg` 里推进的计数器必须按字符数走;而行宽 / 换行 / 补空格必须用
|
|
86
|
+
* `visibleWidth()` 按终端列宽算(CJK 占 2 列)。两个度量在不同地方用,混了就错位。
|
|
87
|
+
* 2. **`\x1b[0m` 会连底色一起清掉**:语法高亮串里若出现全量重置,底色就断了。
|
|
88
|
+
* `injectBg` 在每次遇到 `\x1b[0m` 后重新注入当前底色。同理,前景色收尾一律用
|
|
89
|
+
* `\x1b[39m`(只重置前景)而不是 `\x1b[0m`,否则整行底色会被自己的槽位清掉。
|
|
90
|
+
*
|
|
91
|
+
* 已知取舍(刻意的,别"顺手优化"):
|
|
92
|
+
* - 256color 模式下 `getBgAnsi` / `getFgAnsi` 返回 `48;5;N` / `38;5;N`,拿不到 RGB。
|
|
93
|
+
* 第一版就此放弃了行内底色("宁可少一层强调,也不猜错颜色"),但那个取舍的代价是:
|
|
94
|
+
* 在非 truecolor 终端里整个特性静默消失,用户看不到任何提示。现在改走
|
|
95
|
+
* `deriveWordBg` 的 256 色分支 —— 按 xterm 调色板还原出 RGB(cube + 灰阶爬梯),
|
|
96
|
+
* 混合后再量化回最近的调色板条目,所以 256 色下也有行内底色,只是精度差一点。
|
|
97
|
+
* - 一行被折行时**跳过行内高亮**:ranges 是对未折行全文的字符偏移,续行对不上。
|
|
98
|
+
* - 行内高亮在同一块内按相似度配对(公共前后缀最长的那对先配,每行最多配一次),
|
|
99
|
+
* 且改动占比 > 0.4 就放弃(沿用 CC Fallback.tsx 的阈值)—— 大面积改写时整行底色
|
|
100
|
+
* 比行内高亮更清楚。
|
|
101
|
+
* - LCS 是 O(n·m),长行超过阈值直接放弃行内高亮。
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
import type { EditToolDetails, ExtensionAPI, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
105
|
+
import {
|
|
106
|
+
createEditTool,
|
|
107
|
+
createWriteTool,
|
|
108
|
+
generateUnifiedPatch,
|
|
109
|
+
getLanguageFromPath,
|
|
110
|
+
highlightCode,
|
|
111
|
+
} from "@earendil-works/pi-coding-agent";
|
|
112
|
+
import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
113
|
+
import { readFileSync, statSync } from "node:fs";
|
|
114
|
+
import { homedir } from "node:os";
|
|
115
|
+
import { isAbsolute, resolve } from "node:path";
|
|
116
|
+
// 标题行的排版(预算分配 / 截断 / 闪烁占位 / 行数缩略)在伴生目录 `tool-diff/` 里,
|
|
117
|
+
// 那是个**无 `index.ts` 的纯模块目录**(pi 只加载 `extensions/<name>.ts` 与
|
|
118
|
+
// `extensions/<name>/index.ts`,所以它不会被当成扩展)。
|
|
119
|
+
import { TitleRow, blinkSuffix, shortenPath, statsSuffix, type TitleRowDeps } from "./tool-diff/title-row.ts";
|
|
120
|
+
|
|
121
|
+
/** 折叠态最多渲染的 diff 行数 */
|
|
122
|
+
const PREVIEW_MAX_LINES = 60;
|
|
123
|
+
/**
|
|
124
|
+
* 每处改动前后保留的上下文行数。
|
|
125
|
+
*
|
|
126
|
+
* pi 生成的 unified patch 固定带 4 行上下文(`generateUnifiedPatch(path, old, new,
|
|
127
|
+
* contextLines = 4)`,edit 的 `EditToolDetails.patch` 也是同一个默认值算出来的),
|
|
128
|
+
* 4 行在窄终端里太占地方,这里统一收窄到 2 行 —— 见 `collapseContextRuns`。
|
|
129
|
+
*/
|
|
130
|
+
const CONTEXT_LINES = 2;
|
|
131
|
+
/** ctrl+e 展开后最多渲染的 diff 行数 */
|
|
132
|
+
const EXPANDED_MAX_LINES = 150;
|
|
133
|
+
/** 超过这个字符量就不做语法高亮(highlightCode 有成本) */
|
|
134
|
+
const MAX_HIGHLIGHT_CHARS = 32_000;
|
|
135
|
+
/** 一行最多折成几行,超出用 `›` 截断标记 */
|
|
136
|
+
const MAX_WRAP_ROWS = 3;
|
|
137
|
+
/** 改动占比超过这个值就放弃行内高亮(CC Fallback.tsx 的阈值) */
|
|
138
|
+
const WORD_HIGHLIGHT_CHANGE_RATIO = 0.4;
|
|
139
|
+
/** 行内 LCS 的**字符数**乘积上限,超出就放弃(O(n·m) 成本,500×500 字符 ≈ 1MB DP 表) */
|
|
140
|
+
const WORD_DIFF_CHAR_PRODUCT_LIMIT = 250_000;
|
|
141
|
+
/**
|
|
142
|
+
* 行内底色 = 整行底色朝该侧前景色混合的比例。
|
|
143
|
+
*
|
|
144
|
+
* 混合目标就是同色系的前景色,比例越大行内底色越贴近文字色:0.45 时
|
|
145
|
+
* summer-night 的亮绿 `#42774c` / 亮红 `#96424a` 上语法高亮前景只剩 ≈2-3:1
|
|
146
|
+
* 对比度(注释等深色 token 跌到 ≈1.1:1),字符几乎看不清。
|
|
147
|
+
* 0.30 时行内底色明显亮于整行底色(summer-night:`#052a02` → `#306137`、
|
|
148
|
+
* `#400404` → `#7d3036`,ΔE76 ≈ 22-23),正文对比度 ≈4.1-4.3:1,
|
|
149
|
+
* 高亮片段一眼可辨;代价是注释等深色 token 跌到 ≈1.5-1.9:1,偏淡。
|
|
150
|
+
*/
|
|
151
|
+
const WORD_BG_BLEND = 0.30;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* diff 底色 token 名 —— 从**皮肤文件**取,不在扩展里硬编码任何色值。
|
|
155
|
+
*
|
|
156
|
+
* pi 的主题 schema 没有 diff 底色 token(`Theme.bg()` 只认 7 个 ThemeBg),但 `colors`
|
|
157
|
+
* 里的**自定义 key** pi 照样会解析:`createTheme()` 把解析完(含 `vars` 引用)的颜色按
|
|
158
|
+
* key 分流,不在那 7 个 ThemeBg 名单里的一律进 `fgColors` 表,`getFgAnsi()` 按 key 查表
|
|
159
|
+
* 返回。所以皮肤里写 `"toolDiffAddedBg": "myAddedBg"`(`myAddedBg` 是皮肤 `vars` 里自定的
|
|
160
|
+
* 变量)就能读到,换皮肤底色跟着换 ——
|
|
161
|
+
* 扩展和皮肤解耦,色值只存在于皮肤文件里。
|
|
162
|
+
*
|
|
163
|
+
* 返回的是**前景色** SGR(`\x1b[38;…m`),而底色只差前缀(`\x1b[48;…m`):pi 的 `fgAnsi`
|
|
164
|
+
* 与 `bgAnsi`(theme.js)除了 38 / 48 这一个数字完全相同,truecolor / 256color 的换算也是
|
|
165
|
+
* 同一套(`hexToRgb` / `hexTo256`),所以换前缀就是合法的底色,不用自己算色彩模式。
|
|
166
|
+
*
|
|
167
|
+
* 皮肤没定义这两个 token 时(pi 内置的 dark / light,或别人的皮肤)`getFgAnsi` 会抛
|
|
168
|
+
* `Unknown theme color`,兜底用同一个皮肤的 `toolSuccessBg` / `toolErrorBg` ——
|
|
169
|
+
* 仍然取自皮肤,仍然不硬编码色值。
|
|
170
|
+
*/
|
|
171
|
+
const DIFF_ADDED_BG_TOKEN = "toolDiffAddedBg";
|
|
172
|
+
const DIFF_REMOVED_BG_TOKEN = "toolDiffRemovedBg";
|
|
173
|
+
/** write 读旧内容时的文件大小上限,超出就不算 diff */
|
|
174
|
+
const OLD_CONTENT_MAX_BYTES = 200_000;
|
|
175
|
+
/**
|
|
176
|
+
* 标题行 `+N -M` 的 diff 成本上限(新旧两侧字符数之和),超出就不显示统计。
|
|
177
|
+
* 和 `MAX_HIGHLIGHT_CHARS` 同一个理由:renderCall 在渲染路径上,jsdiff 的行级 diff
|
|
178
|
+
* 在超大文本上不便宜,宁可少一个尾注也不要卡住重绘。
|
|
179
|
+
*/
|
|
180
|
+
const STATS_MAX_CHARS = 200_000;
|
|
181
|
+
|
|
182
|
+
/** 流式加载中标记的闪烁间隔(ms):亮 / 灭各占一个间隔,所以周期是它的两倍 */
|
|
183
|
+
const BLINK_INTERVAL_MS = 500;
|
|
184
|
+
/** 闪烁 tick 上限(≈5 分钟):没有别的信号能停表时的兜底,见 `startBlink` */
|
|
185
|
+
const BLINK_MAX_TICKS = 600;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 标题行与单行提示(Editing… / Applied / Error / no changes)的前导缩进。
|
|
189
|
+
* 工具行原本顶格,跟上方 assistant 文本齐平看不出层次;缩一格后标题 / 提示 / 卡片
|
|
190
|
+
* 形成层级,也和 pi 内置 Box 内容的 paddingX=1 观感一致。
|
|
191
|
+
*/
|
|
192
|
+
const INDENT = " ";
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* diff 行与 hunk 分隔行(`┄`)的前导缩进 —— 对齐 codex 的 diff 观感。
|
|
196
|
+
* codex 的行号列不顶格,整块 diff 相对工具标题往里缩;这里只缩 1 列,和标题 /
|
|
197
|
+
* 提示行的 INDENT 一致 —— 试过 4 列(codex 原样),但窄终端里代码区会被吃掉太多列,
|
|
198
|
+
* 折行明显变多,1 列是层次感与可用宽度的折中。
|
|
199
|
+
* 上下边框(`╌`)**不**跟着缩进 —— 见 `dashedRule`。
|
|
200
|
+
*/
|
|
201
|
+
const DIFF_INDENT = " ";
|
|
202
|
+
|
|
203
|
+
const FG_RESET = "\x1b[39m";
|
|
204
|
+
const BG_RESET = "\x1b[49m";
|
|
205
|
+
const FULL_RESET = "\x1b[0m";
|
|
206
|
+
|
|
207
|
+
type DiffLineType = "add" | "del" | "ctx" | "sep";
|
|
208
|
+
|
|
209
|
+
interface DiffLine {
|
|
210
|
+
type: DiffLineType;
|
|
211
|
+
oldNum: number | null;
|
|
212
|
+
newNum: number | null;
|
|
213
|
+
content: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface ParsedDiff {
|
|
217
|
+
lines: DiffLine[];
|
|
218
|
+
added: number;
|
|
219
|
+
removed: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 一个 SGR 束,按当前主题算一次 */
|
|
223
|
+
interface Sgr {
|
|
224
|
+
BG_ADD: string;
|
|
225
|
+
BG_DEL: string;
|
|
226
|
+
BG_ADD_WORD: string;
|
|
227
|
+
BG_DEL_WORD: string;
|
|
228
|
+
FG_ADD: string;
|
|
229
|
+
FG_DEL: string;
|
|
230
|
+
FG_CTX: string;
|
|
231
|
+
FG_NUM: string;
|
|
232
|
+
FG_RULE: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 宽度无关的预处理结果,避免每次改宽度都重跑 highlightCode */
|
|
236
|
+
interface PreparedRow {
|
|
237
|
+
kind: "line" | "sep";
|
|
238
|
+
type: DiffLineType;
|
|
239
|
+
sign: string;
|
|
240
|
+
numText: string;
|
|
241
|
+
contentHL: string;
|
|
242
|
+
ranges: readonly [number, number][] | undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 行号槽(缩进 + 行号 + 空格 + 符号 + 空格)的总列宽。
|
|
247
|
+
* 顺序按 codex:行号在第一列,符号列紧跟其后,最后才是代码区 ——
|
|
248
|
+
* 早期实现是符号在前、行号在后(`+ 340`),与 codex(`95 +`)不一致。
|
|
249
|
+
* 符号与正文之间只留 1 个空格(codex 原样是 2 个,这里按用户要求收窄到 1)。
|
|
250
|
+
* 折行续行要按这个宽度补空白,才能让代码区左右对齐。
|
|
251
|
+
*/
|
|
252
|
+
function gutterWidthOf(numWidth: number): number {
|
|
253
|
+
return DIFF_INDENT.length + numWidth + 3;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 空白行号槽:折行续行用(不显示行号和 +- 号,只保留底色) */
|
|
257
|
+
function blankGutter(numWidth: number): string {
|
|
258
|
+
return DIFF_INDENT + " ".repeat(numWidth + 3);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
interface WordRanges {
|
|
262
|
+
old: Array<[number, number]>;
|
|
263
|
+
new: Array<[number, number]>;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 行级共享状态里的闪烁字段(edit / write 共用)。
|
|
268
|
+
* pi 的 `context.state` 每个工具行一份(tool-execution.js 的 `rendererState`),
|
|
269
|
+
* renderCall / renderResult 共用同一个对象 —— 所以定时器句柄挂在这里,
|
|
270
|
+
* 另一个 slot 才清得掉它。
|
|
271
|
+
*/
|
|
272
|
+
interface BlinkFields {
|
|
273
|
+
blinkOn?: boolean;
|
|
274
|
+
blinkTicks?: number;
|
|
275
|
+
blinkTimer?: ReturnType<typeof setInterval> | null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 标题行尾部 `(+N -M)` 的行数统计 */
|
|
279
|
+
interface DiffStats {
|
|
280
|
+
added: number;
|
|
281
|
+
removed: number;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 行级共享状态:闪烁字段 + 标题行统计缓存(edit / write 共用)。
|
|
286
|
+
*
|
|
287
|
+
* `stats` 三态:`undefined` = 还没算过,`null` = 算过但算不出来(超大文本 / diff 报错),
|
|
288
|
+
* 否则 = 结果。`null` 也要存下来,不然算不出来的那些行每帧都会重跑一遍 diff。
|
|
289
|
+
*/
|
|
290
|
+
interface RowState extends BlinkFields {
|
|
291
|
+
stats?: DiffStats | null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** write 的渲染器共享状态:存执行前读到的旧内容(`path` 同时充当「这个路径已经抓过」的标记) */
|
|
295
|
+
interface WriteState extends RowState {
|
|
296
|
+
oldContent?: string;
|
|
297
|
+
path?: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// unified patch 解析
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 解析标准 unified patch。`---` / `+++` 只在第一个 hunk 之前才是文件头,
|
|
306
|
+
* 之后所有行都按内容行处理 —— 内容行本身可能以 `-` / `+` 开头(比如 `--verbose`),
|
|
307
|
+
* 全局按前缀判会把它们误当头部丢掉。
|
|
308
|
+
*/
|
|
309
|
+
function parsePatch(patch: string): ParsedDiff {
|
|
310
|
+
const lines: DiffLine[] = [];
|
|
311
|
+
let added = 0;
|
|
312
|
+
let removed = 0;
|
|
313
|
+
let oldLine = 0;
|
|
314
|
+
let newLine = 0;
|
|
315
|
+
let inHunk = false;
|
|
316
|
+
|
|
317
|
+
for (const rawWithCr of patch.split("\n")) {
|
|
318
|
+
// CRLF 源文件会给每行留一个尾随 \r,渲染时会让光标跳回第 0 列、覆盖已画好的行号槽
|
|
319
|
+
const raw = rawWithCr.endsWith("\r") ? rawWithCr.slice(0, -1) : rawWithCr;
|
|
320
|
+
|
|
321
|
+
if (raw.startsWith("@@")) {
|
|
322
|
+
const match = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
323
|
+
if (match === null) continue;
|
|
324
|
+
if (inHunk) lines.push({ type: "sep", oldNum: null, newNum: null, content: "" });
|
|
325
|
+
inHunk = true;
|
|
326
|
+
oldLine = Number(match[1]);
|
|
327
|
+
newLine = Number(match[2]);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!inHunk) {
|
|
332
|
+
// 第一个 @@ 之前的都是头部(diff --git / index / --- / +++)
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (raw.startsWith("\\ No newline")) continue;
|
|
336
|
+
|
|
337
|
+
const marker = raw[0];
|
|
338
|
+
const text = raw.slice(1);
|
|
339
|
+
if (marker === "+") {
|
|
340
|
+
lines.push({ type: "add", oldNum: null, newNum: newLine, content: text });
|
|
341
|
+
newLine += 1;
|
|
342
|
+
added += 1;
|
|
343
|
+
} else if (marker === "-") {
|
|
344
|
+
lines.push({ type: "del", oldNum: oldLine, newNum: null, content: text });
|
|
345
|
+
oldLine += 1;
|
|
346
|
+
removed += 1;
|
|
347
|
+
} else if (marker === " ") {
|
|
348
|
+
lines.push({ type: "ctx", oldNum: oldLine, newNum: newLine, content: text });
|
|
349
|
+
oldLine += 1;
|
|
350
|
+
newLine += 1;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return { lines: collapseContextRuns(lines), added, removed };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 把连续的上下文行收窄到改动两侧各 `CONTEXT_LINES` 行。
|
|
359
|
+
*
|
|
360
|
+
* 为什么在解析之后收窄、而不是让 patch 少生成几行:edit 的 patch 是 pi 给的
|
|
361
|
+
* `EditToolDetails.patch`(上下文行数由 pi 内部固定成 4),扩展改不了它的生成参数。
|
|
362
|
+
* write 的 patch 虽然是本文件自己调 `generateUnifiedPatch` 算的、可以直接传
|
|
363
|
+
* `CONTEXT_LINES`,但 jsdiff 会按新的上下文值**重新切 hunk**(空档 > 2×context 才断开),
|
|
364
|
+
* 于是同样的空档在 edit 里是一条 `┄`、在 write 里变成两条 —— 所以两个工具都走这一条
|
|
365
|
+
* 收窄路径,观感才一致。
|
|
366
|
+
*
|
|
367
|
+
* 规则(与 unified diff 的语义对齐):
|
|
368
|
+
* - hunk 开头的上下文:只留紧贴第一处改动的**后** CONTEXT_LINES 行(前面的直接丢,
|
|
369
|
+
* 行号槽已经说明了文件里从哪一行开始);
|
|
370
|
+
* - hunk 结尾的上下文:只留紧贴上一处改动的**前** CONTEXT_LINES 行;
|
|
371
|
+
* - 两处改动之间的空档:长度 ≤ 2×CONTEXT_LINES 就整段保留(本来挨着的两处改动不该
|
|
372
|
+
* 被拆成两个 hunk),超过才前后各留 CONTEXT_LINES 行、中间插一条 `sep`(与跨 hunk
|
|
373
|
+
* 的分隔符同一个视觉)。
|
|
374
|
+
*
|
|
375
|
+
* 只影响显示:`added` / `removed` 仍按完整 patch 统计(在 parsePatch 里已经算好)。
|
|
376
|
+
*/
|
|
377
|
+
function collapseContextRuns(lines: readonly DiffLine[]): DiffLine[] {
|
|
378
|
+
const out: DiffLine[] = [];
|
|
379
|
+
let i = 0;
|
|
380
|
+
while (i < lines.length) {
|
|
381
|
+
const line = lines[i];
|
|
382
|
+
if (line === undefined) break;
|
|
383
|
+
if (line.type !== "ctx") {
|
|
384
|
+
out.push(line);
|
|
385
|
+
i += 1;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
// 一整段连续的 ctx 行
|
|
389
|
+
let end = i;
|
|
390
|
+
while (end < lines.length && lines[end]?.type === "ctx") end += 1;
|
|
391
|
+
const run = lines.slice(i, end);
|
|
392
|
+
// 前后邻居是不是 hunk 边界(`sep`)或 patch 的首尾
|
|
393
|
+
const atHunkStart = i === 0 || lines[i - 1]?.type === "sep";
|
|
394
|
+
const atHunkEnd = end === lines.length || lines[end]?.type === "sep";
|
|
395
|
+
|
|
396
|
+
if (atHunkStart && atHunkEnd) {
|
|
397
|
+
// 整个 hunk 只有上下文(现实中不会出现,兜底一下)
|
|
398
|
+
out.push(...run.slice(0, CONTEXT_LINES));
|
|
399
|
+
} else if (atHunkStart) {
|
|
400
|
+
out.push(...run.slice(Math.max(0, run.length - CONTEXT_LINES)));
|
|
401
|
+
} else if (atHunkEnd) {
|
|
402
|
+
out.push(...run.slice(0, CONTEXT_LINES));
|
|
403
|
+
} else if (run.length > CONTEXT_LINES * 2) {
|
|
404
|
+
out.push(...run.slice(0, CONTEXT_LINES));
|
|
405
|
+
out.push({ type: "sep", oldNum: null, newNum: null, content: "" });
|
|
406
|
+
out.push(...run.slice(run.length - CONTEXT_LINES));
|
|
407
|
+
} else {
|
|
408
|
+
out.push(...run);
|
|
409
|
+
}
|
|
410
|
+
i = end;
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function maxLineNumber(lines: readonly DiffLine[]): number {
|
|
416
|
+
let max = 0;
|
|
417
|
+
for (const line of lines) {
|
|
418
|
+
// 两个号都要看:unified 里 ctx/add 渲染 newNum、del 渲染 oldNum,
|
|
419
|
+
// 只按其中一个算会让纯新增尾块的行号槽溢出一列
|
|
420
|
+
const value = Math.max(line.oldNum ?? 0, line.newNum ?? 0);
|
|
421
|
+
if (value > max) max = value;
|
|
422
|
+
}
|
|
423
|
+
return max;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// 行内 diff(自己的 LCS,无依赖)
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
function mergeRanges(ranges: Array<[number, number]>): Array<[number, number]> {
|
|
431
|
+
const out: Array<[number, number]> = [];
|
|
432
|
+
for (const [start, end] of ranges) {
|
|
433
|
+
const last = out[out.length - 1];
|
|
434
|
+
if (last !== undefined && last[1] >= start) last[1] = end;
|
|
435
|
+
else out.push([start, end]);
|
|
436
|
+
}
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 行内 LCS。返回的是**原文里的字符偏移区间**(UTF-16 下标),
|
|
442
|
+
* 所以消费方(injectBg)也必须按字符下标推进,不能用终端列宽。
|
|
443
|
+
*
|
|
444
|
+
* 按**字符**而不是按词做 LCS。按词做 diff 会把 `computeTimeout` →
|
|
445
|
+
* `computeTimeoutFromProfile` 当成两个不同的 token(整词替换,14+25 个字符全算变更,
|
|
446
|
+
* 改动占比 0.47)而在这 0.4 的闸门处整对丢掉 —— 而这恰恰是代码里最常见的改动:
|
|
447
|
+
* 标识符加长 / 换名 / 追加参数。字符级 LCS 只标出真正新增的 `FromProfile`(11 个字符,
|
|
448
|
+
* 占比 0.13),高亮落点更准;实测会话日志里 339 个 1 删 1 增块中有 131 个(38.6%)
|
|
449
|
+
* 是被整词粒度误杀的。
|
|
450
|
+
*
|
|
451
|
+
* 两道降本措施(O(n·m) 的 DP 表在长行上很贵 —— 本仓库 README 里那种上千字符的表格行就是):
|
|
452
|
+
* 1. 先掐掉公共前后缀,只在**两侧真正不同的那一段**上跑 LCS。长行通常只改中间一小段,
|
|
453
|
+
* 掐完往往只剩几个字符;
|
|
454
|
+
* 2. 掐完还是太大(`WORD_DIFF_CHAR_PRODUCT_LIMIT`)就直接放弃行内高亮,只留整行底色。
|
|
455
|
+
* DP 表用扁平 `Int32Array`(不建 `number[][]`:后者是 n+1 个 JS 数组,开销大得多)。
|
|
456
|
+
*/
|
|
457
|
+
function computeWordRanges(oldText: string, newText: string): WordRanges | undefined {
|
|
458
|
+
if (oldText === newText) return undefined;
|
|
459
|
+
|
|
460
|
+
// 公共前后缀:两侧逐字符相同的开头 / 结尾,本来就不是改动
|
|
461
|
+
let prefix = 0;
|
|
462
|
+
const maxPrefix = Math.min(oldText.length, newText.length);
|
|
463
|
+
while (prefix < maxPrefix && oldText[prefix] === newText[prefix]) prefix += 1;
|
|
464
|
+
let oldEnd = oldText.length;
|
|
465
|
+
let newEnd = newText.length;
|
|
466
|
+
while (oldEnd > prefix && newEnd > prefix && oldText[oldEnd - 1] === newText[newEnd - 1]) {
|
|
467
|
+
oldEnd -= 1;
|
|
468
|
+
newEnd -= 1;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const oldMid = oldText.slice(prefix, oldEnd);
|
|
472
|
+
const newMid = newText.slice(prefix, newEnd);
|
|
473
|
+
const n = oldMid.length;
|
|
474
|
+
const m = newMid.length;
|
|
475
|
+
if (n * m > WORD_DIFF_CHAR_PRODUCT_LIMIT) return undefined;
|
|
476
|
+
|
|
477
|
+
const width = m + 1;
|
|
478
|
+
const dp = new Int32Array((n + 1) * width);
|
|
479
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
480
|
+
const row = i * width;
|
|
481
|
+
const below = row + width;
|
|
482
|
+
const oldChar = oldMid[i];
|
|
483
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
484
|
+
dp[row + j] =
|
|
485
|
+
oldChar === newMid[j] ? dp[below + j + 1] + 1 : Math.max(dp[below + j], dp[row + j + 1]);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const oldRanges: Array<[number, number]> = [];
|
|
490
|
+
const newRanges: Array<[number, number]> = [];
|
|
491
|
+
let oi = 0;
|
|
492
|
+
let nj = 0;
|
|
493
|
+
let changed = 0;
|
|
494
|
+
|
|
495
|
+
while (oi < n && nj < m) {
|
|
496
|
+
if (oldMid[oi] === newMid[nj]) {
|
|
497
|
+
oi += 1;
|
|
498
|
+
nj += 1;
|
|
499
|
+
} else if (dp[(oi + 1) * width + nj] >= dp[oi * width + nj + 1]) {
|
|
500
|
+
oldRanges.push([prefix + oi, prefix + oi + 1]);
|
|
501
|
+
changed += 1;
|
|
502
|
+
oi += 1;
|
|
503
|
+
} else {
|
|
504
|
+
newRanges.push([prefix + nj, prefix + nj + 1]);
|
|
505
|
+
changed += 1;
|
|
506
|
+
nj += 1;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
while (oi < n) {
|
|
510
|
+
oldRanges.push([prefix + oi, prefix + oi + 1]);
|
|
511
|
+
changed += 1;
|
|
512
|
+
oi += 1;
|
|
513
|
+
}
|
|
514
|
+
while (nj < m) {
|
|
515
|
+
newRanges.push([prefix + nj, prefix + nj + 1]);
|
|
516
|
+
changed += 1;
|
|
517
|
+
nj += 1;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 占比按**整行**算(分母不是掐完的中间段):两侧相同的部分不算改动,但分母用整行长度,
|
|
521
|
+
// 「长行里改一小段」与「短行里改一小段」才是同一个口径(也就是 CC 的 changed/total)。
|
|
522
|
+
const changeRatio = changed / (oldText.length + newText.length);
|
|
523
|
+
if (changeRatio > WORD_HIGHLIGHT_CHANGE_RATIO) return undefined;
|
|
524
|
+
return { old: mergeRanges(oldRanges), new: mergeRanges(newRanges) };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* 两行的相似度代理:公共前后缀的字符数之和。O(长度),用来在块内选配对,不跑 LCS。
|
|
529
|
+
* 代码行的改动绝大多数是「中间一小段不同」或者「尾部追加」,前后缀都能很好地反映相似度;
|
|
530
|
+
* 而完全无关的两行在这个指标上会很低。
|
|
531
|
+
*/
|
|
532
|
+
function commonAffixLength(a: string, b: string): number {
|
|
533
|
+
const maxPrefix = Math.min(a.length, b.length);
|
|
534
|
+
let prefix = 0;
|
|
535
|
+
while (prefix < maxPrefix && a[prefix] === b[prefix]) prefix += 1;
|
|
536
|
+
let suffix = 0;
|
|
537
|
+
while (suffix < maxPrefix - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) suffix += 1;
|
|
538
|
+
return prefix + suffix;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* 把一个「N 删 + M 增」块内的行两两配对,每行最多配一次。
|
|
543
|
+
*
|
|
544
|
+
* 早期只在严格 1 删 1 增时配对(`visible[i + 2]` 还是 add 就整块放弃),理由是
|
|
545
|
+
* 「一个 `-` 后面跟多条 `+` 属于块替换,做行内高亮会误导」。代价是:多行改写完全
|
|
546
|
+
* 拿不到行内高亮 —— 会话日志里约 670 个相邻 -/+ 块只有一半是 1:1,另一种形状
|
|
547
|
+
* (1 删 + N 增 / N 删 + M 增)的约 330 个块、2500 行改动全部只有整行底色。
|
|
548
|
+
*
|
|
549
|
+
* 配对规则**不是** CC 那样的「按位置逐行配」(`wordDiff` 用的是 min 长度逐位置),
|
|
550
|
+
* 而是块内按相似度贪心:把 D×A 个候选对按
|
|
551
|
+
* `commonAffixLength` 从高到低排,依次取走两端都没配过的对。理由是位置配会在块内
|
|
552
|
+
* 行数不等时配错人 —— 例如
|
|
553
|
+
*
|
|
554
|
+
* -const value = 1;
|
|
555
|
+
* +const extra = 'x'; ← 新插进来的一行
|
|
556
|
+
* +const value = 2;
|
|
557
|
+
*
|
|
558
|
+
* 位置配会把 `value = 1` 配给 `extra = 'x'`(实测这个对的改动占比 0.35,刚好越过 0.4
|
|
559
|
+
* 闸门,于是画出两段毫无意义的红/绿碎片),而相似度配会把它配给 `value = 2`,
|
|
560
|
+
* 高亮落在那一个数字上。同分时保持原顺序(`sort` 稳定),所以 D=A、各行都改动时
|
|
561
|
+
* 退化成等价于位置配。真正对不上的行会在 `computeWordRanges` 的 0.4 闸门处被丢掉,
|
|
562
|
+
* 退化成整行底色,不会画出误导性的高亮。
|
|
563
|
+
*
|
|
564
|
+
* 返回的 map 按 **del 行和 add 行两个下标都建键**,各自指向自己一侧的区间。
|
|
565
|
+
* 早期只按 del 的下标建键,而 prepareRows 对 add 行查的是 add 自己的下标,
|
|
566
|
+
* 于是新增侧的行内高亮永远 miss(删除侧正常)—— 两侧观感不对称。
|
|
567
|
+
*/
|
|
568
|
+
function pairWordRanges(visible: readonly DiffLine[]): Map<number, readonly [number, number][]> {
|
|
569
|
+
const out = new Map<number, readonly [number, number][]>();
|
|
570
|
+
let i = 0;
|
|
571
|
+
while (i < visible.length) {
|
|
572
|
+
if (visible[i]?.type !== "del") {
|
|
573
|
+
i += 1;
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
// [i, delEnd) 是连续的删除行,[delEnd, addEnd) 是紧跟其后的连续新增行
|
|
577
|
+
let delEnd = i;
|
|
578
|
+
while (visible[delEnd]?.type === "del") delEnd += 1;
|
|
579
|
+
let addEnd = delEnd;
|
|
580
|
+
while (visible[addEnd]?.type === "add") addEnd += 1;
|
|
581
|
+
|
|
582
|
+
const candidates: Array<{ del: number; add: number; score: number }> = [];
|
|
583
|
+
for (let d = i; d < delEnd; d += 1) {
|
|
584
|
+
const del = visible[d];
|
|
585
|
+
if (del === undefined) continue;
|
|
586
|
+
for (let a = delEnd; a < addEnd; a += 1) {
|
|
587
|
+
const add = visible[a];
|
|
588
|
+
if (add === undefined) continue;
|
|
589
|
+
candidates.push({ del: d, add: a, score: commonAffixLength(del.content, add.content) });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// Array.prototype.sort 稳定,同分即保持位置顺序(等价于 CC 的位置配)
|
|
593
|
+
candidates.sort((left, right) => right.score - left.score);
|
|
594
|
+
|
|
595
|
+
const pairedDel = new Set<number>();
|
|
596
|
+
const pairedAdd = new Set<number>();
|
|
597
|
+
for (const candidate of candidates) {
|
|
598
|
+
if (pairedDel.has(candidate.del) || pairedAdd.has(candidate.add)) continue;
|
|
599
|
+
const del = visible[candidate.del];
|
|
600
|
+
const add = visible[candidate.add];
|
|
601
|
+
if (del === undefined || add === undefined) continue;
|
|
602
|
+
pairedDel.add(candidate.del);
|
|
603
|
+
pairedAdd.add(candidate.add);
|
|
604
|
+
const ranges = computeWordRanges(del.content, add.content);
|
|
605
|
+
if (ranges === undefined) continue;
|
|
606
|
+
out.set(candidate.del, ranges.old);
|
|
607
|
+
out.set(candidate.add, ranges.new);
|
|
608
|
+
}
|
|
609
|
+
i = addEnd > delEnd ? addEnd : delEnd;
|
|
610
|
+
}
|
|
611
|
+
return out;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
// 颜色
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
type Rgb = readonly [number, number, number];
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* xterm 256 色调色板 → RGB。`Theme` 在 256color 模式下只留索引(`38;5;N`),
|
|
622
|
+
* 混合前必须先把索引还原成 RGB:16-231 是 6×6×6 立方(每级 0 / 95 / 135 / 175 / 215 / 255),
|
|
623
|
+
* 232-255 是 24 级灰阶(8 + 10·n)。0-15 是终端自己的系统色,各终端不一样,这里按
|
|
624
|
+
* xterm 默认值取,但**量化时不用**它们(见 `nearestXterm256`)。
|
|
625
|
+
*/
|
|
626
|
+
const XTERM_256_RGB: readonly Rgb[] = (() => {
|
|
627
|
+
const system: Rgb[] = [
|
|
628
|
+
[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
|
|
629
|
+
[0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
|
|
630
|
+
[128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
|
|
631
|
+
[0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255],
|
|
632
|
+
];
|
|
633
|
+
const levels = [0, 95, 135, 175, 215, 255];
|
|
634
|
+
const cube: Rgb[] = [];
|
|
635
|
+
for (let r = 0; r < 6; r += 1) {
|
|
636
|
+
for (let g = 0; g < 6; g += 1) {
|
|
637
|
+
for (let b = 0; b < 6; b += 1) cube.push([levels[r] ?? 0, levels[g] ?? 0, levels[b] ?? 0]);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const ramp: Rgb[] = [];
|
|
641
|
+
for (let n = 0; n < 24; n += 1) {
|
|
642
|
+
const v = 8 + n * 10;
|
|
643
|
+
ramp.push([v, v, v]);
|
|
644
|
+
}
|
|
645
|
+
return [...system, ...cube, ...ramp];
|
|
646
|
+
})();
|
|
647
|
+
|
|
648
|
+
/** 两个 RGB 的加权平方距离(绿通道权重最高,人眼对绿最敏感;与 pi 的 `rgbTo256` 同口径) */
|
|
649
|
+
function rgbDistance(a: Rgb, b: Rgb): number {
|
|
650
|
+
const dr = a[0] - b[0];
|
|
651
|
+
const dg = a[1] - b[1];
|
|
652
|
+
const db = a[2] - b[2];
|
|
653
|
+
return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* 就近取一个 256 色索引,规则与 pi 的 `rgbTo256` 一致:先在 6×6×6 立方里取最近的一格,
|
|
658
|
+
* 再和 24 级灰阶爬梯比,**但只有颜色接近中性(三通道极差 < 10)时才允许倒向灰阶**。
|
|
659
|
+
* 那条饱和度守卫不能省:行内底色是带色调的(红=删 / 绿=增),而未加权的最邻近搜索会把
|
|
660
|
+
* `#287126`(混合后的绿行底色)量化成灰阶 239(`#4e4e4e`)—— 灰条落在绿行上,语义就丢了。
|
|
661
|
+
*
|
|
662
|
+
* 0-15 的 16 个系统色不参与匹配:它们由终端主题决定,同一个索引在不同终端里差别很大。
|
|
663
|
+
*/
|
|
664
|
+
function nearestXterm256(rgb: Rgb): number {
|
|
665
|
+
const levels = [0, 95, 135, 175, 215, 255];
|
|
666
|
+
const closest = (value: number): number => {
|
|
667
|
+
let index = 0;
|
|
668
|
+
let best = Number.POSITIVE_INFINITY;
|
|
669
|
+
for (let i = 0; i < levels.length; i += 1) {
|
|
670
|
+
const distance = Math.abs(value - (levels[i] ?? 0));
|
|
671
|
+
if (distance < best) {
|
|
672
|
+
best = distance;
|
|
673
|
+
index = i;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return index;
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const rIndex = closest(rgb[0]);
|
|
680
|
+
const gIndex = closest(rgb[1]);
|
|
681
|
+
const bIndex = closest(rgb[2]);
|
|
682
|
+
const cubeIndex = 16 + 36 * rIndex + 6 * gIndex + bIndex;
|
|
683
|
+
const cube: Rgb = [levels[rIndex] ?? 0, levels[gIndex] ?? 0, levels[bIndex] ?? 0];
|
|
684
|
+
const cubeDistance = rgbDistance(rgb, cube);
|
|
685
|
+
|
|
686
|
+
const grayValue = Math.round(0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]);
|
|
687
|
+
let grayIndex = 0;
|
|
688
|
+
let grayDistance = Number.POSITIVE_INFINITY;
|
|
689
|
+
for (let n = 0; n < 24; n += 1) {
|
|
690
|
+
const value = 8 + n * 10;
|
|
691
|
+
const distance = Math.abs(grayValue - value);
|
|
692
|
+
if (distance < grayDistance) {
|
|
693
|
+
grayDistance = distance;
|
|
694
|
+
grayIndex = n;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const gray = 8 + grayIndex * 10;
|
|
698
|
+
const spread = Math.max(...rgb) - Math.min(...rgb);
|
|
699
|
+
if (spread < 10 && rgbDistance(rgb, [gray, gray, gray]) < cubeDistance) return 232 + grayIndex;
|
|
700
|
+
return cubeIndex;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** `\x1b[38;2;…m` / `\x1b[48;2;…m`(truecolor)或 `\x1b[38|48;5;Nm`(256 色索引)→ RGB */
|
|
704
|
+
function parseRgb(ansi: string): Rgb | undefined {
|
|
705
|
+
const truecolor = ansi.match(/\x1b\[[34]8;2;(\d{1,3});(\d{1,3});(\d{1,3})m/);
|
|
706
|
+
if (truecolor !== null) return [Number(truecolor[1]), Number(truecolor[2]), Number(truecolor[3])];
|
|
707
|
+
const indexed = ansi.match(/\x1b\[[34]8;5;(\d{1,3})m/);
|
|
708
|
+
if (indexed !== null) return XTERM_256_RGB[Number(indexed[1])];
|
|
709
|
+
return undefined;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* 从皮肤读一个 diff 底色 token,并转成底色 SGR(`38;…` → `48;…`,见上面的注释)。
|
|
714
|
+
* 皮肤没定义时(`getFgAnsi` 抛 `Unknown theme color`)回退到 `fallback` 那个 ThemeBg。
|
|
715
|
+
* 颜色值是空串时 pi 返回 `\x1b[39m`(前景重置),换不了前缀,同样走兜底。
|
|
716
|
+
*/
|
|
717
|
+
function diffBgFromTheme(theme: Theme, token: string, fallback: "toolSuccessBg" | "toolErrorBg"): string {
|
|
718
|
+
let ansi: string;
|
|
719
|
+
try {
|
|
720
|
+
ansi = theme.getFgAnsi(token as ThemeColor);
|
|
721
|
+
} catch {
|
|
722
|
+
return theme.getBgAnsi(fallback);
|
|
723
|
+
}
|
|
724
|
+
if (!ansi.startsWith("\x1b[38;")) return theme.getBgAnsi(fallback);
|
|
725
|
+
return ansi.replace("\x1b[38;", "\x1b[48;");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* 行内底色 = 整行底色朝该侧前景色混合。这样底色跟皮肤走,不硬编码。
|
|
730
|
+
*
|
|
731
|
+
* 输入是 256 色索引(`48;5;N`)时就**回去也是索引** —— 只支持 256 色的终端里发 truecolor SGR
|
|
732
|
+
* 多半会被当成未知颜色丢掉,那样还不如不发。量化回索引走 `nearestXterm256`,粒度粗一些,
|
|
733
|
+
* 但混合的方向不变(混合后的颜色一定比整行底色更靠近前景色,量化误差不足以推翻它)。
|
|
734
|
+
*/
|
|
735
|
+
function deriveWordBg(baseBgAnsi: string, fgAnsi: string): string {
|
|
736
|
+
const base = parseRgb(baseBgAnsi);
|
|
737
|
+
const fg = parseRgb(fgAnsi);
|
|
738
|
+
if (base === undefined || fg === undefined) return baseBgAnsi;
|
|
739
|
+
const r = Math.round(base[0] + (fg[0] - base[0]) * WORD_BG_BLEND);
|
|
740
|
+
const g = Math.round(base[1] + (fg[1] - base[1]) * WORD_BG_BLEND);
|
|
741
|
+
const b = Math.round(base[2] + (fg[2] - base[2]) * WORD_BG_BLEND);
|
|
742
|
+
if (baseBgAnsi.startsWith("\x1b[48;5;")) return `\x1b[48;5;${nearestXterm256([r, g, b])}m`;
|
|
743
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function buildSgr(theme: Theme): Sgr {
|
|
747
|
+
// 整行底色从皮肤取:优先皮肤自定义的 toolDiffAddedBg / toolDiffRemovedBg,
|
|
748
|
+
// 皮肤没定义就退回同一个皮肤的 toolSuccessBg / toolErrorBg —— 两条路都不硬编码色值。
|
|
749
|
+
const BG_ADD = diffBgFromTheme(theme, DIFF_ADDED_BG_TOKEN, "toolSuccessBg");
|
|
750
|
+
const BG_DEL = diffBgFromTheme(theme, DIFF_REMOVED_BG_TOKEN, "toolErrorBg");
|
|
751
|
+
const FG_ADD = theme.getFgAnsi("toolDiffAdded");
|
|
752
|
+
const FG_DEL = theme.getFgAnsi("toolDiffRemoved");
|
|
753
|
+
return {
|
|
754
|
+
BG_ADD,
|
|
755
|
+
BG_DEL,
|
|
756
|
+
BG_ADD_WORD: deriveWordBg(BG_ADD, FG_ADD),
|
|
757
|
+
BG_DEL_WORD: deriveWordBg(BG_DEL, FG_DEL),
|
|
758
|
+
FG_ADD,
|
|
759
|
+
FG_DEL,
|
|
760
|
+
FG_CTX: theme.getFgAnsi("toolDiffContext"),
|
|
761
|
+
FG_NUM: theme.getFgAnsi("dim"),
|
|
762
|
+
FG_RULE: theme.getFgAnsi("dim"),
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* 给一行(可能已带语法高亮 ANSI)叠底色,并在指定字符区间换成行内底色。
|
|
768
|
+
*
|
|
769
|
+
* 计数器按**字符下标**推进(与 `computeWordRanges` 的偏移同一坐标系),不是终端列宽。
|
|
770
|
+
* 遇到 `\x1b[0m` 必须重新注入底色 —— 全量重置会把背景一起清掉。
|
|
771
|
+
*/
|
|
772
|
+
function injectBg(
|
|
773
|
+
line: string,
|
|
774
|
+
baseBg: string,
|
|
775
|
+
ranges: readonly [number, number][] | undefined,
|
|
776
|
+
wordBg: string,
|
|
777
|
+
): string {
|
|
778
|
+
let out = baseBg;
|
|
779
|
+
let charIndex = 0;
|
|
780
|
+
let inWord = false;
|
|
781
|
+
let rangeIndex = 0;
|
|
782
|
+
let i = 0;
|
|
783
|
+
|
|
784
|
+
while (i < line.length) {
|
|
785
|
+
if (line[i] === "\x1b") {
|
|
786
|
+
const end = line.indexOf("m", i);
|
|
787
|
+
if (end !== -1) {
|
|
788
|
+
const sequence = line.slice(i, end + 1);
|
|
789
|
+
out += sequence;
|
|
790
|
+
if (sequence === FULL_RESET) out += inWord ? wordBg : baseBg;
|
|
791
|
+
i = end + 1;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
let range = ranges?.[rangeIndex];
|
|
797
|
+
while (range !== undefined && charIndex >= range[1]) {
|
|
798
|
+
rangeIndex += 1;
|
|
799
|
+
range = ranges?.[rangeIndex];
|
|
800
|
+
}
|
|
801
|
+
const want = range !== undefined && charIndex >= range[0] && charIndex < range[1];
|
|
802
|
+
if (want !== inWord) {
|
|
803
|
+
inWord = want;
|
|
804
|
+
out += inWord ? wordBg : baseBg;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
out += line[i] ?? "";
|
|
808
|
+
charIndex += 1;
|
|
809
|
+
i += 1;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return out + BG_RESET;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
// 渲染
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
|
|
819
|
+
function highlightLines(source: readonly string[], language: string | undefined): string[] {
|
|
820
|
+
if (language === undefined || source.length === 0) return [...source];
|
|
821
|
+
const joined = source.join("\n");
|
|
822
|
+
if (joined.length > MAX_HIGHLIGHT_CHARS) return [...source];
|
|
823
|
+
try {
|
|
824
|
+
const highlighted = highlightCode(joined, language);
|
|
825
|
+
// 行数对不上就不能按索引映射,退回原文
|
|
826
|
+
return highlighted.length === source.length ? highlighted : [...source];
|
|
827
|
+
} catch {
|
|
828
|
+
return [...source];
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* 宽度无关的预处理:语法高亮 + 行内配对 + 行号槽文本。
|
|
834
|
+
* 高亮分两侧算(老侧 = ctx+del,新侧 = ctx+add),与 CC / 参考实现一致。
|
|
835
|
+
*/
|
|
836
|
+
function prepareRows(
|
|
837
|
+
s: Sgr,
|
|
838
|
+
visible: readonly DiffLine[],
|
|
839
|
+
language: string | undefined,
|
|
840
|
+
gutterWidth: number,
|
|
841
|
+
): PreparedRow[] {
|
|
842
|
+
const oldSource: string[] = [];
|
|
843
|
+
const newSource: string[] = [];
|
|
844
|
+
for (const line of visible) {
|
|
845
|
+
if (line.type === "ctx" || line.type === "del") oldSource.push(line.content);
|
|
846
|
+
if (line.type === "ctx" || line.type === "add") newSource.push(line.content);
|
|
847
|
+
}
|
|
848
|
+
const oldHL = highlightLines(oldSource, language);
|
|
849
|
+
const newHL = highlightLines(newSource, language);
|
|
850
|
+
const wordMap = pairWordRanges(visible);
|
|
851
|
+
|
|
852
|
+
const rows: PreparedRow[] = [];
|
|
853
|
+
let oi = 0;
|
|
854
|
+
let ni = 0;
|
|
855
|
+
for (let i = 0; i < visible.length; i += 1) {
|
|
856
|
+
const line = visible[i];
|
|
857
|
+
if (line === undefined) continue;
|
|
858
|
+
|
|
859
|
+
if (line.type === "sep") {
|
|
860
|
+
rows.push({ kind: "sep", type: "sep", sign: "", numText: "", contentHL: "", ranges: undefined });
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
let contentHL: string;
|
|
865
|
+
let ranges: readonly [number, number][] | undefined;
|
|
866
|
+
if (line.type === "del") {
|
|
867
|
+
contentHL = oldHL[oi] ?? line.content;
|
|
868
|
+
oi += 1;
|
|
869
|
+
ranges = wordMap.get(i);
|
|
870
|
+
} else if (line.type === "add") {
|
|
871
|
+
contentHL = newHL[ni] ?? line.content;
|
|
872
|
+
ni += 1;
|
|
873
|
+
ranges = wordMap.get(i);
|
|
874
|
+
} else {
|
|
875
|
+
// ctx 两侧都有,取新侧;两个游标都要推进
|
|
876
|
+
contentHL = newHL[ni] ?? line.content;
|
|
877
|
+
oi += 1;
|
|
878
|
+
ni += 1;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const num = line.type === "del" ? line.oldNum : line.newNum;
|
|
882
|
+
const numText =
|
|
883
|
+
num === null ? " ".repeat(gutterWidth) : " ".repeat(Math.max(0, gutterWidth - String(num).length)) + String(num);
|
|
884
|
+
const sign = line.type === "add" ? "+" : line.type === "del" ? "-" : " ";
|
|
885
|
+
|
|
886
|
+
rows.push({ kind: "line", type: line.type, sign, numText, contentHL, ranges });
|
|
887
|
+
}
|
|
888
|
+
return rows;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** 按宽度排版:补底色到满宽、必要时折行 */
|
|
892
|
+
function layoutRows(s: Sgr, rows: readonly PreparedRow[], width: number, gutterWidth: number): string[] {
|
|
893
|
+
// 槽位 = 缩进(DIFF_INDENT) + 行号(gutterWidth) + 空格(1) + 符号(1) + 空格(1)
|
|
894
|
+
const prefixWidth = gutterWidthOf(gutterWidth);
|
|
895
|
+
const contentWidth = Math.max(1, width - prefixWidth);
|
|
896
|
+
const out: string[] = [];
|
|
897
|
+
|
|
898
|
+
for (const row of rows) {
|
|
899
|
+
if (row.kind === "sep") {
|
|
900
|
+
out.push(`${DIFF_INDENT}${s.FG_RULE}${"┄".repeat(Math.max(0, width - DIFF_INDENT.length))}${FG_RESET}`);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const bg = row.type === "add" ? s.BG_ADD : row.type === "del" ? s.BG_DEL : "";
|
|
905
|
+
const wordBg = row.type === "add" ? s.BG_ADD_WORD : row.type === "del" ? s.BG_DEL_WORD : "";
|
|
906
|
+
const signFg = row.type === "add" ? s.FG_ADD : row.type === "del" ? s.FG_DEL : s.FG_CTX;
|
|
907
|
+
// 行号在前、符号在后,符号与正文之间一个空格(`95 - code` / `94 code`)。
|
|
908
|
+
// 行号跟符号同色(ctx 行没有符号,保持原来的 dim)。
|
|
909
|
+
const numFg = row.type === "ctx" ? s.FG_NUM : signFg;
|
|
910
|
+
const gutterText = `${DIFF_INDENT}${numFg}${row.numText}${FG_RESET} ${signFg}${row.sign}${FG_RESET} `;
|
|
911
|
+
// 底色从**整行最左侧**开始下(含 DIFF_INDENT 那 1 列),槽位内部一律只用 FG_RESET
|
|
912
|
+
// (只重置前景)收尾,所以行号 / 符号的收尾不会把底色清掉 —— 缩进 + 行号槽 + 代码区
|
|
913
|
+
// 连成一条完整的底色带,而不是只有代码区有底色。
|
|
914
|
+
const gutter = bg === "" ? gutterText : bg + gutterText;
|
|
915
|
+
const blank = bg === "" ? blankGutter(gutterWidth) : bg + blankGutter(gutterWidth);
|
|
916
|
+
|
|
917
|
+
const wrapped = wrapTextWithAnsi(row.contentHL, contentWidth);
|
|
918
|
+
const limited = wrapped.length > MAX_WRAP_ROWS ? wrapped.slice(0, MAX_WRAP_ROWS) : wrapped;
|
|
919
|
+
for (let r = 0; r < limited.length; r += 1) {
|
|
920
|
+
const rowText = limited[r] ?? "";
|
|
921
|
+
// 折行时跳过行内高亮:ranges 是对未折行全文的字符偏移,续行对不上
|
|
922
|
+
const effectiveRanges = wrapped.length === 1 ? row.ranges : undefined;
|
|
923
|
+
const hasMarker = wrapped.length > MAX_WRAP_ROWS && r === limited.length - 1;
|
|
924
|
+
// 截断标记要占 1 列,而折出来的最后一行本身已经正好是 contentWidth 宽,
|
|
925
|
+
// 所以光减少 padding 不够 —— 必须把这行裁掉 1 列,否则整行会超宽 1 列
|
|
926
|
+
// (CJK 宽字符尤其明显:每个字占 2 列,误差不会被“刚好差一个空格”掩盖)。
|
|
927
|
+
const usable = hasMarker ? Math.max(1, contentWidth - 1) : contentWidth;
|
|
928
|
+
const clipped = visibleWidth(rowText) > usable ? truncateToWidth(rowText, usable, "") : rowText;
|
|
929
|
+
const padWidth = Math.max(0, usable - visibleWidth(clipped));
|
|
930
|
+
// 标记放在底色内部,让色调铺满整行宽度而不是提前 1 列结束
|
|
931
|
+
const marker = hasMarker ? `${s.FG_CTX}›${FG_RESET}` : "";
|
|
932
|
+
const bodyText = clipped + " ".repeat(padWidth) + marker;
|
|
933
|
+
const body = bg === "" ? bodyText : injectBg(bodyText, bg, effectiveRanges, wordBg);
|
|
934
|
+
// 折行续行不带行号和 +- 号(codex 同款):槽位整段留空,只靠底色表明归属,
|
|
935
|
+
// 重复打同一个行号会让人误以为是多条独立改动。槽位留空但底色照旧铺满,
|
|
936
|
+
// 否则折行部分会比首行短一截。
|
|
937
|
+
out.push((r === 0 ? gutter : blank) + body);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return out;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* 上下虚线框:**顶格**、铺满整行宽度 —— 它是卡片的边框而不是 diff 内容,所以不跟
|
|
945
|
+
* diff 行的缩进走(缩进后框会比内容窄一列,看上去像框没合拢)。宽度直接取 width,
|
|
946
|
+
* 不减缩进。
|
|
947
|
+
*/
|
|
948
|
+
function dashedRule(s: Sgr, width: number): string {
|
|
949
|
+
return `${s.FG_RULE}${"╌".repeat(Math.max(0, width))}${FG_RESET}`;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* 结果卡片组件。`render(width)` 每帧都会调,所以按宽度缓存排版结果;
|
|
954
|
+
* 语法高亮在构造时就算好了(宽度无关),不会每帧重跑。
|
|
955
|
+
*/
|
|
956
|
+
class DiffCard {
|
|
957
|
+
private readonly cache = new Map<number, string[]>();
|
|
958
|
+
|
|
959
|
+
constructor(private readonly layout: (width: number) => string[]) {}
|
|
960
|
+
|
|
961
|
+
render(width: number): string[] {
|
|
962
|
+
const hit = this.cache.get(width);
|
|
963
|
+
if (hit !== undefined) return hit;
|
|
964
|
+
const lines = this.layout(width);
|
|
965
|
+
this.cache.set(width, lines);
|
|
966
|
+
return lines;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
invalidate(): void {
|
|
970
|
+
this.cache.clear();
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function buildDiffCard(
|
|
975
|
+
theme: Theme,
|
|
976
|
+
diff: ParsedDiff,
|
|
977
|
+
language: string | undefined,
|
|
978
|
+
expanded: boolean,
|
|
979
|
+
): DiffCard {
|
|
980
|
+
const s = buildSgr(theme);
|
|
981
|
+
const maxLines = expanded ? EXPANDED_MAX_LINES : PREVIEW_MAX_LINES;
|
|
982
|
+
const visible = diff.lines.slice(0, maxLines);
|
|
983
|
+
const hidden = diff.lines.length - visible.length;
|
|
984
|
+
const gutterWidth = Math.max(2, String(maxLineNumber(visible)).length);
|
|
985
|
+
const rows = prepareRows(s, visible, language, gutterWidth);
|
|
986
|
+
|
|
987
|
+
return new DiffCard((width: number) => {
|
|
988
|
+
// 行数统计已经搬到标题行的 `(+N -M)`,卡片直接从上下虚线框开始,
|
|
989
|
+
// 不再多一行 `Added N lines, removed M lines` 把同一份改动报两遍。
|
|
990
|
+
if (rows.length === 0) return [];
|
|
991
|
+
const out: string[] = [dashedRule(s, width)];
|
|
992
|
+
out.push(...layoutRows(s, rows, width, gutterWidth));
|
|
993
|
+
out.push(dashedRule(s, width));
|
|
994
|
+
if (hidden > 0) {
|
|
995
|
+
// 提示行没有底色可以靠 padding 占位,文本长了就会直接超宽,所以按宽度截断
|
|
996
|
+
out.push(
|
|
997
|
+
DIFF_INDENT +
|
|
998
|
+
truncateToWidth(
|
|
999
|
+
theme.fg(
|
|
1000
|
+
"muted",
|
|
1001
|
+
`… ${hidden} more diff ${hidden === 1 ? "line" : "lines"}${expanded ? "" : " (ctrl+e to expand)"}`,
|
|
1002
|
+
),
|
|
1003
|
+
Math.max(1, width - DIFF_INDENT.length),
|
|
1004
|
+
"…",
|
|
1005
|
+
),
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
return out;
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* 两段文本之间的 +/- 行数。用与结果卡片同一套 `generateUnifiedPatch` + `parsePatch`,
|
|
1015
|
+
* 所以标题行的数字和卡片里画出来的行数是同一个口径。
|
|
1016
|
+
* 算不出来(超大文本 / 报错)返回 undefined,标题行就不带尾注。
|
|
1017
|
+
*/
|
|
1018
|
+
function diffStatsOf(oldText: string, newText: string): DiffStats | undefined {
|
|
1019
|
+
if (oldText === newText) return { added: 0, removed: 0 };
|
|
1020
|
+
if (oldText.length + newText.length > STATS_MAX_CHARS) return undefined;
|
|
1021
|
+
try {
|
|
1022
|
+
// path 只进 patch 头部,parsePatch 会跳过头部,所以传空串就够
|
|
1023
|
+
const parsed = parsePatch(generateUnifiedPatch("", oldText, newText));
|
|
1024
|
+
return { added: parsed.added, removed: parsed.removed };
|
|
1025
|
+
} catch {
|
|
1026
|
+
return undefined;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* edit 的行数统计:逐条 edits 算 oldText → newText 再求和。
|
|
1032
|
+
* 各处改动落在文件里互不重叠的区域,所以求和等于整文件 patch 的 +/- 行数
|
|
1033
|
+
* (相邻改动被 jsdiff 合成一个 hunk 也只影响上下文,不影响 +/- 计数)。
|
|
1034
|
+
*/
|
|
1035
|
+
function editStats(args: unknown): DiffStats | undefined {
|
|
1036
|
+
const edits = (args as { edits?: unknown } | undefined)?.edits;
|
|
1037
|
+
if (!Array.isArray(edits) || edits.length === 0) return undefined;
|
|
1038
|
+
let added = 0;
|
|
1039
|
+
let removed = 0;
|
|
1040
|
+
for (const edit of edits) {
|
|
1041
|
+
const entry = edit as { oldText?: unknown; newText?: unknown } | undefined;
|
|
1042
|
+
if (entry === undefined || entry === null) continue;
|
|
1043
|
+
const stats = diffStatsOf(
|
|
1044
|
+
typeof entry.oldText === "string" ? entry.oldText : "",
|
|
1045
|
+
typeof entry.newText === "string" ? entry.newText : "",
|
|
1046
|
+
);
|
|
1047
|
+
if (stats === undefined) return undefined;
|
|
1048
|
+
added += stats.added;
|
|
1049
|
+
removed += stats.removed;
|
|
1050
|
+
}
|
|
1051
|
+
return { added, removed };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** 统计只算一次,之后每帧复用(renderCall 会被反复调用:重绘、改宽度、展开折叠) */
|
|
1055
|
+
function cachedStats(state: RowState | undefined, compute: () => DiffStats | undefined): DiffStats | undefined {
|
|
1056
|
+
if (state === undefined) return compute();
|
|
1057
|
+
if (state.stats === undefined) state.stats = compute() ?? null;
|
|
1058
|
+
return state.stats ?? undefined;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* 流式期间让标题尾部的 `●` 闪烁,返回当前相位(true = 亮)。
|
|
1064
|
+
*
|
|
1065
|
+
* 为什么用定时器而不是「跟着 delta 闪」:pi 的 tool-execution.js 里
|
|
1066
|
+
* `context.invalidate()` = `ToolExecutionComponent.invalidate()`(→ `updateDisplay()`
|
|
1067
|
+
* 重新调用 renderCall)+ `ui.requestRender()`,所以翻相位 + invalidate 就能自己驱动
|
|
1068
|
+
* 重绘 —— 节拍固定,不受 delta 到达节奏影响(delta 停一会儿也不会僵在某一相位上)。
|
|
1069
|
+
*
|
|
1070
|
+
* 停表有三处(前两处是正常路径,第三处是兜底):
|
|
1071
|
+
* 1. renderCall 的 `argsComplete` 分支(参数流结束);
|
|
1072
|
+
* 2. `renderResult`(结果已到)。它也覆盖中断 / 报错的行 —— interactive-mode.js 在
|
|
1073
|
+
* `stopReason=aborted|error` 时对所有 pending 行调 `updateResult`,而不是
|
|
1074
|
+
* `setArgsComplete`,所以那种行永远走不到 1;
|
|
1075
|
+
* 3. tick 上限。兜的是「行既没有结果、也永远等不到 argsComplete」:例如会话在工具
|
|
1076
|
+
* 调用中途被杀掉后恢复,那一行的 stopReason 不是 aborted/error、也没有 toolResult。
|
|
1077
|
+
* (有结果的历史行不走这条:renderCall 的 `isPartial` 分支当场就把表停了。)
|
|
1078
|
+
* 正常单轮 write/edit 的参数流远不到这个上限。
|
|
1079
|
+
*
|
|
1080
|
+
* `unref()` 让定时器不阻止进程退出。
|
|
1081
|
+
*/
|
|
1082
|
+
function startBlink(state: BlinkFields, invalidate: () => void): boolean {
|
|
1083
|
+
if (state.blinkTimer === undefined || state.blinkTimer === null) {
|
|
1084
|
+
state.blinkOn = true;
|
|
1085
|
+
state.blinkTicks = 0;
|
|
1086
|
+
state.blinkTimer = setInterval(() => {
|
|
1087
|
+
state.blinkTicks = (state.blinkTicks ?? 0) + 1;
|
|
1088
|
+
if ((state.blinkTicks ?? 0) > BLINK_MAX_TICKS) {
|
|
1089
|
+
stopBlink(state);
|
|
1090
|
+
invalidate();
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
state.blinkOn = !(state.blinkOn ?? false);
|
|
1094
|
+
invalidate();
|
|
1095
|
+
}, BLINK_INTERVAL_MS);
|
|
1096
|
+
state.blinkTimer.unref?.();
|
|
1097
|
+
}
|
|
1098
|
+
return state.blinkOn !== false;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** 停掉闪烁定时器并回到灭态。幂等 —— 没起过定时器时调用也安全。 */
|
|
1102
|
+
function stopBlink(state: BlinkFields | undefined): void {
|
|
1103
|
+
if (state === undefined) return;
|
|
1104
|
+
if (state.blinkTimer !== undefined && state.blinkTimer !== null) clearInterval(state.blinkTimer);
|
|
1105
|
+
state.blinkTimer = null;
|
|
1106
|
+
state.blinkOn = false;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* 标题行。path 传空串就整段省略(流式开头路径还没解析出来时的形态是 `Edit ●`),
|
|
1112
|
+
* suffix 传空串就省略。suffix 必须是**已着色**的串 —— 闪烁标记要单独上色,而
|
|
1113
|
+
* `theme.fg("dim", 已着色串)` 的外层 reset 会把内层 ANSI 一起吃掉。
|
|
1114
|
+
* path 相反,传**纯文本**:`TitleRow.render` 要先按宽度从头部截断再上色。
|
|
1115
|
+
*
|
|
1116
|
+
* 排版依赖显式接上 pi-tui 的量度/截断函数(纯模块那头不知道 pi 的存在),
|
|
1117
|
+
* home 取一次就存着(进程内不会变)。
|
|
1118
|
+
*/
|
|
1119
|
+
const TITLE_ROW_DEPS: TitleRowDeps = {
|
|
1120
|
+
widthOf: visibleWidth,
|
|
1121
|
+
truncateToWidth,
|
|
1122
|
+
home: homedir(),
|
|
1123
|
+
indent: INDENT,
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
function titleRow(theme: Theme, tool: string, path: string, suffix: string): TitleRow {
|
|
1127
|
+
return new TitleRow(TITLE_ROW_DEPS, theme, theme.fg("toolTitle", theme.bold(tool)), path, suffix);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** 单行提示(Editing… / Error / Applied / Written / no changes)。
|
|
1131
|
+
* 和结果卡片占同一个视觉位置,所以也带同样的缩进 —— 否则同一个工具在不同结果下
|
|
1132
|
+
* 一会儿缩进一会儿顶格,观感不一致。 */
|
|
1133
|
+
function noteLine(theme: Theme, color: "dim" | "error" | "success" | "muted", text: string): Text {
|
|
1134
|
+
return new Text(INDENT + theme.fg(color, text), 0, 0);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function firstText(result: { content: readonly { type: string; text?: string }[] }): string {
|
|
1138
|
+
const first = result.content[0];
|
|
1139
|
+
return first !== undefined && first.type === "text" ? first.text ?? "" : "";
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function resolvePath(cwd: string, path: string): string {
|
|
1143
|
+
return isAbsolute(path) ? path : resolve(cwd, path);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// ---------------------------------------------------------------------------
|
|
1147
|
+
// 注册
|
|
1148
|
+
// ---------------------------------------------------------------------------
|
|
1149
|
+
|
|
1150
|
+
export default function (pi: ExtensionAPI): void {
|
|
1151
|
+
const cwd = process.cwd();
|
|
1152
|
+
|
|
1153
|
+
// --- edit:renderCall 只留一行标题,不显示内置那份 diff 预览 ---
|
|
1154
|
+
const originalEdit = createEditTool(cwd);
|
|
1155
|
+
pi.registerTool({
|
|
1156
|
+
...originalEdit,
|
|
1157
|
+
renderShell: "self",
|
|
1158
|
+
renderCall(args, theme, context) {
|
|
1159
|
+
const path = String(args?.path ?? "");
|
|
1160
|
+
const state = context.state as RowState | undefined;
|
|
1161
|
+
// 加载中显示工具名 + 路径 + 闪烁的 `●`(表示「正在接收字节流」);token 数不在这里
|
|
1162
|
+
// 滚动 —— 底部 spinner 已经在数当前段的产出量(working-indicator/),重复。
|
|
1163
|
+
// `+N -M` 只在参数收完后显示 —— 流式途中 edits 数组可能还没解析出来,算出来的
|
|
1164
|
+
// 行数会一路跳。
|
|
1165
|
+
// path 有就显示、没就省略:实测 `parseStreamingJson` 对未闭合的字符串会返回
|
|
1166
|
+
// **部分值**(`{"path": "/a/b/ser` → `"/a/b/ser"`),所以流式开头会短暂看到逐字符
|
|
1167
|
+
// 增长的半截路径;但 path 是 schema 的第一个字段、字符串很短,几个 token 就闭合了,
|
|
1168
|
+
// 之后路径稳定而 content 还要流很久 —— 所以尽早显示比完全不显示更有用。
|
|
1169
|
+
// `isPartial` 只在 `updateResult` 之后才变 false(tool-execution.js),正好区分
|
|
1170
|
+
// 「真的在流式」和「结果已到但 argsComplete 永远不会变 true」的行 —— 后者是
|
|
1171
|
+
// 中断的行和恢复历史会话渲染出来的行(renderSessionItems 从不调 setArgsComplete)。
|
|
1172
|
+
// 那种行不闪也不画标记(否则历史区每行都会挂一个静态的 `●`),但照样算 `+N -M`:
|
|
1173
|
+
// 会话日志里的参数是完整的,历史区不能没有行数信息。
|
|
1174
|
+
if (!context.argsComplete && context.isPartial) {
|
|
1175
|
+
// state 拿不到时不闪,但仍然画出标记(宁可静态一点也不丢提示)
|
|
1176
|
+
const on = state === undefined ? true : startBlink(state, context.invalidate);
|
|
1177
|
+
return titleRow(theme, "Edit", path, blinkSuffix(theme, on));
|
|
1178
|
+
}
|
|
1179
|
+
// 参数流结束(或结果已到):停表,恢复完整形态(路径 + `+N -M`)
|
|
1180
|
+
stopBlink(state);
|
|
1181
|
+
return titleRow(theme, "Edit", path, statsSuffix(theme, cachedStats(state, () => editStats(args))));
|
|
1182
|
+
},
|
|
1183
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1184
|
+
// 结果已到就停表(中断 / 报错的行也走这里);partial 结果不算 —— 那时参数可能还在流。
|
|
1185
|
+
if (!isPartial) stopBlink(context.state as RowState | undefined);
|
|
1186
|
+
if (isPartial) return noteLine(theme, "dim", "Editing…");
|
|
1187
|
+
|
|
1188
|
+
if (context.isError) {
|
|
1189
|
+
return noteLine(theme, "error", firstText(result).split("\n")[0] || "Edit failed");
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const details = result.details as EditToolDetails | undefined;
|
|
1193
|
+
const patch = details?.patch ?? details?.diff ?? "";
|
|
1194
|
+
if (patch === "") return noteLine(theme, "success", "Applied");
|
|
1195
|
+
|
|
1196
|
+
const diff = parsePatch(patch);
|
|
1197
|
+
if (diff.lines.length === 0) return noteLine(theme, "success", "Applied");
|
|
1198
|
+
|
|
1199
|
+
const path = String((context.args as { path?: unknown } | undefined)?.path ?? "");
|
|
1200
|
+
return buildDiffCard(theme, diff, getLanguageFromPath(path), expanded);
|
|
1201
|
+
},
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
// --- write:自带 renderCall(要在执行前读旧内容),renderResult 画真 diff ---
|
|
1205
|
+
const originalWrite = createWriteTool(cwd);
|
|
1206
|
+
pi.registerTool({
|
|
1207
|
+
...originalWrite,
|
|
1208
|
+
renderShell: "self",
|
|
1209
|
+
renderCall(args, theme, context) {
|
|
1210
|
+
const state = context.state as WriteState | undefined;
|
|
1211
|
+
const path = String(args?.path ?? "");
|
|
1212
|
+
// 在执行开始前抓旧内容,并且**路径每变一次就重抓一次**:流式途中 path 是逐字符
|
|
1213
|
+
// 增长的(`parseStreamingJson` 对未闭合的字符串返回部分值),半截路径要么 stat 失败
|
|
1214
|
+
// (看上去像新建文件,于是覆盖已有文件被整份算成新增),要么恰好命中另一个文件。
|
|
1215
|
+
// 以 path 为键重抓,最后一帧(`argsComplete`,早于 `tool_execution_start`)拿到的一定
|
|
1216
|
+
// 是完整路径,所以最终值总是对的。一旦 executionStarted 就已经写完,再读拿到的是
|
|
1217
|
+
// 新内容,diff 会全空 —— 那个窗口一关就彻底停手。
|
|
1218
|
+
if (state !== undefined && path !== "" && !context.executionStarted && state.path !== path) {
|
|
1219
|
+
state.path = path;
|
|
1220
|
+
try {
|
|
1221
|
+
const absolute = resolvePath(context.cwd, path);
|
|
1222
|
+
const size = statSync(absolute).size;
|
|
1223
|
+
state.oldContent = size <= OLD_CONTENT_MAX_BYTES ? readFileSync(absolute, "utf-8") : "";
|
|
1224
|
+
} catch {
|
|
1225
|
+
// 文件不存在(新建)或读不了 → 按空内容处理,全部算新增
|
|
1226
|
+
state.oldContent = "";
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// 旧内容抓取必须在下面的显示分支之前:它依赖的是「执行还没开始」,
|
|
1231
|
+
// 而不是「参数已完整」—— 拖到 argsComplete 才读就可能赶不上写入前的窗口。
|
|
1232
|
+
// 加载中显示工具名 + 路径 + 闪烁的 `●`(与 edit 一致的形态);token 数交给底部
|
|
1233
|
+
// spinner 滚动,标题行不再重复。`isPartial` 的语义与 edit 那边相同。
|
|
1234
|
+
if (!context.argsComplete && context.isPartial) {
|
|
1235
|
+
const on = state === undefined ? true : startBlink(state, context.invalidate);
|
|
1236
|
+
return titleRow(theme, "Write", path, blinkSuffix(theme, on));
|
|
1237
|
+
}
|
|
1238
|
+
// 参数流结束(或结果已到):停表,恢复完整形态(路径 + `+N -M`)。
|
|
1239
|
+
// 统计用的是执行前抓到的旧内容 vs 参数里的新内容 —— 与 renderResult 画卡片时
|
|
1240
|
+
// 同一对输入,所以标题的数字和卡片里的行数不会打架。
|
|
1241
|
+
stopBlink(state);
|
|
1242
|
+
const content = String(args?.content ?? "");
|
|
1243
|
+
const stats = cachedStats(state, () => diffStatsOf(state?.oldContent ?? "", content));
|
|
1244
|
+
return titleRow(theme, "Write", path, statsSuffix(theme, stats));
|
|
1245
|
+
},
|
|
1246
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1247
|
+
// 结果已到就停表(中断 / 报错的行也走这里);partial 结果不算 —— 那时参数可能还在流。
|
|
1248
|
+
if (!isPartial) stopBlink(context.state as WriteState | undefined);
|
|
1249
|
+
if (isPartial) return noteLine(theme, "dim", "Writing…");
|
|
1250
|
+
|
|
1251
|
+
const text = firstText(result);
|
|
1252
|
+
if (context.isError || text.startsWith("Error")) {
|
|
1253
|
+
return noteLine(theme, "error", text.split("\n")[0] || "Write failed");
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const state = context.state as WriteState | undefined;
|
|
1257
|
+
const path = String((context.args as { path?: unknown } | undefined)?.path ?? "");
|
|
1258
|
+
const content = String((context.args as { content?: unknown } | undefined)?.content ?? "");
|
|
1259
|
+
if (path === "" || content === "") return noteLine(theme, "success", "Written");
|
|
1260
|
+
|
|
1261
|
+
const oldContent = state?.oldContent ?? "";
|
|
1262
|
+
if (oldContent === content) return noteLine(theme, "muted", "no changes");
|
|
1263
|
+
|
|
1264
|
+
let patch: string;
|
|
1265
|
+
try {
|
|
1266
|
+
patch = generateUnifiedPatch(path, oldContent, content);
|
|
1267
|
+
} catch {
|
|
1268
|
+
return noteLine(theme, "success", "Written");
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const diff = parsePatch(patch);
|
|
1272
|
+
if (diff.lines.length === 0) return noteLine(theme, "success", "Written");
|
|
1273
|
+
return buildDiffCard(theme, diff, getLanguageFromPath(path), expanded);
|
|
1274
|
+
},
|
|
1275
|
+
});
|
|
1276
|
+
}
|