@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,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-runtime — 两个补丁型扩展(lean-tidy / sticky-first-message)
|
|
3
|
+
* 共用的「pi 运行时类定位」模块。放在 extensions/_shared/ 下、无 index.ts,pi 的加载器
|
|
4
|
+
* 不会把它当扩展加载(只加载 extensions/*.ts 与 extensions/<dir>/index.ts)。
|
|
5
|
+
*
|
|
6
|
+
* ── 为什么要共用 ──────────────────────────────────────────────────────────
|
|
7
|
+
* 三家都要拿到 pi 内部类(InteractiveMode / CustomEditor / *Component)打 prototype
|
|
8
|
+
* 补丁。此前各自扫一遍 dist/bundle/chunks(48 个文件)与 dist/modes/interactive*,实测:
|
|
9
|
+
* - chunks 首次 ~70ms(47 个是 bedrock/azure 等 provider 库,从不导出 UI 类;只有
|
|
10
|
+
* 主 chunk 导出 UI 类,而它运行时早已加载,import 为 0ms)
|
|
11
|
+
* - dist/modes/interactive + components ~430ms —— 这是**未打包的第二份副本**,本机 pi
|
|
12
|
+
* 由 dist/bundle/cli.js 启动,这份副本运行时根本不用,扫它纯属浪费(还把整份 pi
|
|
13
|
+
* 再加载一遍进内存)
|
|
14
|
+
* 本模块:进程内只扫一次(globalThis 备忘,/reload 零成本),bundle 运行时不再碰
|
|
15
|
+
* dist/modes/interactive*,chunks 按 pi 版本缓存「含 UI 类的文件清单」(正常只剩主 chunk)。
|
|
16
|
+
*
|
|
17
|
+
* ── 类来源与顺序 ──────────────────────────────────────────────────────────
|
|
18
|
+
* 1. `import * as piPkg from "@earendil-works/pi-coding-agent"` —— bundle 运行时经 jiti
|
|
19
|
+
* virtualModules 拿到的就是 bundle 活类;上面所有需要的类它都导出。放第一位。
|
|
20
|
+
* 2. chunks 里含 UI 类的文件(缓存命中时只 import 清单里的;未命中全扫一次并写缓存)
|
|
21
|
+
* 3. 非 bundle 运行时(离线测试)或 PI_RUNTIME_SCAN=full:再扫 dist/modes/interactive*
|
|
22
|
+
* 调用方拿到 modules 数组后按自己的名字/形状规则打补丁;FLAG 幂等,重复出现的类无害。
|
|
23
|
+
*
|
|
24
|
+
* ── 开关 ──────────────────────────────────────────────────────────────────
|
|
25
|
+
* PI_RUNTIME_PKG_ROOT(旧名 PI_LEAN_TIDY_PKG_ROOT 仍兼容)覆盖 pi 包根
|
|
26
|
+
* PI_RUNTIME_SCAN=full(旧名 PI_LEAN_TIDY_SCAN)强制全量扫描
|
|
27
|
+
* 缓存文件:$PI_CODING_AGENT_DIR/pi-runtime.cache.json(默认 ~/.pi/agent/)
|
|
28
|
+
*/
|
|
29
|
+
import * as piPkg from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { createRequire } from "node:module";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { dirname, join, relative } from "node:path";
|
|
34
|
+
import { pathToFileURL } from "node:url";
|
|
35
|
+
|
|
36
|
+
/** 补丁扩展逐版本验收过的 pi 版本;不一致时由 lean-tidy 在 session_start 提示一次。
|
|
37
|
+
* 0.99.1 核验(2026-09-29):skill-block-recollapse 8/8 PASS(含 interactive-mode.js:4497
|
|
38
|
+
* fork 选择器仍造 {id,text} 副本、TreeList 200 字符截断一致);sticky-first-message 全 PASS;
|
|
39
|
+
* pi-claude-code(lean-tidy) 除 CustomEntryComponent 已移出公共导出(形状识别不命中,
|
|
40
|
+
* 无伤退化为原生渲染)外全 PASS。
|
|
41
|
+
* 0.99.2 核验(2026-09-30):changelog 仅 MCP/codemode/provider 变更;skill-block-recollapse
|
|
42
|
+
* 全部导出/形状检查 PASS,interactive-mode.js:4497 fork 副本一字未动,TreeList 仍截 200;
|
|
43
|
+
* sticky-first-message bun test 40/40 PASS;lean-tidy 形状识别类全在(CustomEntryComponent
|
|
44
|
+
* 仍缺,同 0.99.1 无伤退化),tests 7/8 PASS——header.test.mjs 失败系其引用的
|
|
45
|
+
* custom-header.ts 已删除(功能并入本扩展),与 pi 版本无关。
|
|
46
|
+
* 1.0.0 核验(2026-10-02):changelog 的 TUI 改动「user message 不再保留两份全宽副本」
|
|
47
|
+
* 删了 UserMessageComponent 里的 Box(Markdown 直接落 children[0] 且自带 paddingX)——
|
|
48
|
+
* lean-tidy renderGeneric 的两层结构假设失配退化为原生渲染,已修复(?? 回退取 Markdown +
|
|
49
|
+
* 临时置零 paddingX 再 invalidate,claude-view.ts)。skill-block-recollapse:fork 选择器
|
|
50
|
+
* 仍進 {id,text} 副本(interactive-mode.js:4497→4531),TreeList 仍截 200
|
|
51
|
+
* (tree-selector.js:730),全部依赖导出仍在。sticky bun 40/40;tests 共 60/60 PASS
|
|
52
|
+
* (working-message 测试已同步为纯计时行为,系 2026-10-01 应一南要求移除 token 计数,
|
|
53
|
+
* 与 pi 版本无关)。
|
|
54
|
+
* 1.0.2 核验(2026-10-04):1.0.1/1.0.2 changelog 均无 TUI 内部变更(前者为 Nix/MCP 项目
|
|
55
|
+
* 覆盖/OAuth CIMD/registerToolRenderer API/prompt cache,后者仅 samplingParamsByThinkingLevel)。
|
|
56
|
+
* skill-block-recollapse:fork 选择器仍造 {id,text} 副本(interactive-mode.js:4538→由 1.0.0
|
|
57
|
+
* 的 4531 偏移,代码一字未动);TreeList 仍截 200(tree-selector.js:730);
|
|
58
|
+
* TreeSelectorComponent/UserMessageSelectorComponent/SessionManager/AgentSession 导出全在。
|
|
59
|
+
* lean-tidy:UserMessageComponent 仍是无 Box 直接 addChild(Markdown) 结构(其源码注释即
|
|
60
|
+
* “a Box would keep a second full-width copy”,1.0.0 修复假设匹配);sticky bun 40/40 PASS。 */
|
|
61
|
+
export const VERIFIED_PI_VERSION = "1.0.2";
|
|
62
|
+
|
|
63
|
+
export interface RuntimeModule {
|
|
64
|
+
/** 来源标记:"<package>" 或相对 pkgRoot 的文件路径 */
|
|
65
|
+
where: string;
|
|
66
|
+
mod: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PiRuntime {
|
|
70
|
+
pkgRoot: string;
|
|
71
|
+
piVersion: string;
|
|
72
|
+
/** 是否由 dist/bundle/cli.js 启动(本机常态) */
|
|
73
|
+
bundleRuntime: boolean;
|
|
74
|
+
/** "<package>" 永远在首位 */
|
|
75
|
+
modules: RuntimeModule[];
|
|
76
|
+
/** 本次 import 的文件(相对 pkgRoot) */
|
|
77
|
+
files: string[];
|
|
78
|
+
cacheHit: boolean;
|
|
79
|
+
ms: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const AGENT_DIR = (process.env.PI_CODING_AGENT_DIR || "").trim() || join(homedir(), ".pi", "agent");
|
|
83
|
+
const CACHE_PATH = join(AGENT_DIR, "pi-runtime.cache.json");
|
|
84
|
+
const MEMO = Symbol.for("pi-runtime/scan");
|
|
85
|
+
const FALLBACK_ROOT = "/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent";
|
|
86
|
+
|
|
87
|
+
const env = (k: string) => (process.env[k] || "").trim();
|
|
88
|
+
|
|
89
|
+
/** pi 包根:env → CLI 入口(dist/bundle/cli.js)→ 模块解析 → node/bun 全局前缀 → 硬编码回退 */
|
|
90
|
+
export function resolvePkgRoot(): string {
|
|
91
|
+
const override = env("PI_RUNTIME_PKG_ROOT") || env("PI_LEAN_TIDY_PKG_ROOT");
|
|
92
|
+
if (override) return override;
|
|
93
|
+
const candidates: string[] = [];
|
|
94
|
+
try {
|
|
95
|
+
const cliJs = realpathSync(process.argv[1] ?? "");
|
|
96
|
+
if (cliJs.includes(join("dist", "bundle"))) candidates.push(dirname(dirname(dirname(cliJs))));
|
|
97
|
+
} catch {}
|
|
98
|
+
try {
|
|
99
|
+
const req = createRequire(import.meta.url);
|
|
100
|
+
let d = dirname(req.resolve("@earendil-works/pi-coding-agent"));
|
|
101
|
+
for (let i = 0; i < 6 && !existsSync(join(d, "package.json")); i++) d = dirname(d);
|
|
102
|
+
candidates.push(d);
|
|
103
|
+
} catch {}
|
|
104
|
+
try {
|
|
105
|
+
const prefix = dirname(dirname(realpathSync(process.execPath)));
|
|
106
|
+
candidates.push(join(prefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent"));
|
|
107
|
+
} catch {}
|
|
108
|
+
candidates.push(FALLBACK_ROOT);
|
|
109
|
+
return candidates.find((p) => existsSync(join(p, "dist", "bundle"))) ?? candidates[candidates.length - 1];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isBundleRuntime(): boolean {
|
|
113
|
+
try {
|
|
114
|
+
return realpathSync(process.argv[1] ?? "").includes(join("dist", "bundle"));
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readPiVersion(pkgRoot: string): string {
|
|
121
|
+
try {
|
|
122
|
+
return String(JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8")).version ?? "");
|
|
123
|
+
} catch {
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 模块命名空间是否导出「UI 类」:大写开头的函数,且 prototype 上有渲染/事件方法。 */
|
|
129
|
+
const UI_METHODS = ["render", "handleEvent", "handleInput", "handlePaste", "updateDisplay"];
|
|
130
|
+
function exportsUiClass(mod: Record<string, unknown>): boolean {
|
|
131
|
+
for (const key of Object.keys(mod)) {
|
|
132
|
+
if (!/^[A-Z]/.test(key)) continue;
|
|
133
|
+
let v: unknown;
|
|
134
|
+
try {
|
|
135
|
+
v = mod[key];
|
|
136
|
+
} catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (typeof v !== "function") continue;
|
|
140
|
+
const proto = (v as { prototype?: Record<string, unknown> }).prototype;
|
|
141
|
+
if (proto && UI_METHODS.some((m) => typeof proto[m] === "function")) return true;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function importModule(file: string): Promise<Record<string, unknown> | null> {
|
|
147
|
+
try {
|
|
148
|
+
return (await import(pathToFileURL(file).href)) as Record<string, unknown>;
|
|
149
|
+
} catch {
|
|
150
|
+
return null; // worker chunk 等在主线程 import 会失败——跳过
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function scan(): Promise<PiRuntime> {
|
|
155
|
+
const t0 = Date.now();
|
|
156
|
+
const pkgRoot = resolvePkgRoot();
|
|
157
|
+
const bundleRuntime = isBundleRuntime();
|
|
158
|
+
const piVersion = readPiVersion(pkgRoot);
|
|
159
|
+
const full = (env("PI_RUNTIME_SCAN") || env("PI_LEAN_TIDY_SCAN")).toLowerCase() === "full" || !bundleRuntime;
|
|
160
|
+
const scope = full ? "full" : "bundle";
|
|
161
|
+
|
|
162
|
+
const modules: RuntimeModule[] = [{ where: "<package>", mod: piPkg as unknown as Record<string, unknown> }];
|
|
163
|
+
const files: string[] = [];
|
|
164
|
+
let cacheHit = false;
|
|
165
|
+
|
|
166
|
+
// 缓存:{ piVersion, scope, files } —— 命中时只 import 清单里的文件
|
|
167
|
+
let cached: string[] | undefined;
|
|
168
|
+
try {
|
|
169
|
+
const c = JSON.parse(readFileSync(CACHE_PATH, "utf8"));
|
|
170
|
+
if (c?.piVersion === piVersion && c?.scope === scope && Array.isArray(c?.files) && c.files.every((f: unknown) => typeof f === "string")) {
|
|
171
|
+
const abs = (c.files as string[]).map((f) => join(pkgRoot, f));
|
|
172
|
+
if (abs.every((f) => existsSync(f))) cached = abs;
|
|
173
|
+
}
|
|
174
|
+
} catch {}
|
|
175
|
+
|
|
176
|
+
if (cached) {
|
|
177
|
+
cacheHit = true;
|
|
178
|
+
for (const f of cached) {
|
|
179
|
+
const mod = await importModule(f);
|
|
180
|
+
if (mod) {
|
|
181
|
+
modules.push({ where: relative(pkgRoot, f), mod });
|
|
182
|
+
files.push(relative(pkgRoot, f));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
const dirs = [join(pkgRoot, "dist", "bundle", "chunks")];
|
|
187
|
+
if (full) dirs.push(join(pkgRoot, "dist", "modes", "interactive", "components"), join(pkgRoot, "dist", "modes", "interactive"));
|
|
188
|
+
for (const dir of dirs) {
|
|
189
|
+
let names: string[] = [];
|
|
190
|
+
try {
|
|
191
|
+
names = readdirSync(dir).filter((f) => f.endsWith(".js"));
|
|
192
|
+
} catch {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
for (const name of names) {
|
|
196
|
+
const abs = join(dir, name);
|
|
197
|
+
const mod = await importModule(abs);
|
|
198
|
+
if (!mod || !exportsUiClass(mod)) continue;
|
|
199
|
+
modules.push({ where: relative(pkgRoot, abs), mod });
|
|
200
|
+
files.push(relative(pkgRoot, abs));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
writeFileSync(CACHE_PATH, `${JSON.stringify({ piVersion, scope, files }, null, 2)}\n`, "utf8");
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
return { pkgRoot, piVersion, bundleRuntime, modules, files, cacheHit, ms: Date.now() - t0 };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 进程内只扫一次(Promise 备忘在 globalThis,/reload 后新模块实例直接复用)。
|
|
212
|
+
* 扫描抛错时退化为只含 "<package>" 的结果,绝不让调用方加载失败。
|
|
213
|
+
*/
|
|
214
|
+
export function getPiRuntime(): Promise<PiRuntime> {
|
|
215
|
+
const g = globalThis as Record<PropertyKey, unknown>;
|
|
216
|
+
let memo = g[MEMO] as Promise<PiRuntime> | undefined;
|
|
217
|
+
if (!memo) {
|
|
218
|
+
memo = scan().catch(
|
|
219
|
+
(): PiRuntime => ({
|
|
220
|
+
pkgRoot: resolvePkgRoot(),
|
|
221
|
+
piVersion: "",
|
|
222
|
+
bundleRuntime: isBundleRuntime(),
|
|
223
|
+
modules: [{ where: "<package>", mod: piPkg as unknown as Record<string, unknown> }],
|
|
224
|
+
files: [],
|
|
225
|
+
cacheHit: false,
|
|
226
|
+
ms: 0,
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
g[MEMO] = memo;
|
|
230
|
+
}
|
|
231
|
+
return memo;
|
|
232
|
+
}
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性预览 harness(跑完即删):驱动 claude-view.ts 的真实 renderChat,
|
|
3
|
+
* 模拟「连续工具轮次」时间线,截取 sticky 修复后各时刻的 transcript 形态。
|
|
4
|
+
*/
|
|
5
|
+
import { createClaudeView } from "./claude-view.ts";
|
|
6
|
+
import { stripStyle } from "./claude-tools.ts";
|
|
7
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
const W = 76;
|
|
10
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
const theme = {
|
|
12
|
+
name: "preview",
|
|
13
|
+
fg: (_t: string, s: string) => s,
|
|
14
|
+
bold: (s: string) => s,
|
|
15
|
+
italic: (s: string) => s,
|
|
16
|
+
getBgAnsi: () => "",
|
|
17
|
+
getFgAnsi: () => "",
|
|
18
|
+
};
|
|
19
|
+
const config = {
|
|
20
|
+
bashPreviewLines: 1, codePreviewLines: 3, errorPreviewLines: 1,
|
|
21
|
+
detailsBackground: "selectedBg", liveThinking: true, thinkingTailLines: 3, foldAnimMs: 240,
|
|
22
|
+
skillRendering: "native" as const,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const wrapPlain = (text: string, width: number): string[] => {
|
|
26
|
+
const out: string[] = [];
|
|
27
|
+
for (const para of text.split("\n")) {
|
|
28
|
+
const chars = Array.from(para);
|
|
29
|
+
if (!chars.length) { out.push(""); continue; }
|
|
30
|
+
for (let i = 0; i < chars.length; i += width) out.push(chars.slice(i, i + width).join(""));
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const makeUser = (text: string) => ({ render: () => [` ❯ ${text}`] });
|
|
36
|
+
|
|
37
|
+
function makeAssistant(o: { thinking?: string; text?: string; streaming?: boolean; msg: any }) {
|
|
38
|
+
const children: any[] = [];
|
|
39
|
+
if (o.thinking !== undefined)
|
|
40
|
+
children.push({ child: { render: (w: number) => wrapPlain(o.thinking!, w) }, onMouse() {} });
|
|
41
|
+
if (o.text !== undefined) children.push({ render: (w: number) => wrapPlain(o.text!, w) });
|
|
42
|
+
return {
|
|
43
|
+
updateContent() {}, setHideThinkingBlock() {},
|
|
44
|
+
contentContainer: { children },
|
|
45
|
+
thinkingVisibilityOverrides: new Map(),
|
|
46
|
+
hideThinkingBlock: true,
|
|
47
|
+
lastMessage: o.msg,
|
|
48
|
+
isStreaming: !!o.streaming,
|
|
49
|
+
markdownTheme: getMarkdownTheme(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let toolSeq = 0;
|
|
54
|
+
function makeTool(name: string, args: any, o: { result?: any; partial?: boolean } = {}) {
|
|
55
|
+
return {
|
|
56
|
+
toolName: name, toolCallId: `t${++toolSeq}`, args,
|
|
57
|
+
result: o.result, isPartial: !!o.partial, executionStarted: true,
|
|
58
|
+
expanded: false, cwd: "/Users/chenyn/Downloads/demo",
|
|
59
|
+
setExpanded() {}, updateDisplay() {}, imageComponents: [], hideComponent: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const T1 = [
|
|
64
|
+
"用户想检查项目结构。",
|
|
65
|
+
"先列目录、数行数,把整体规模摸清楚。",
|
|
66
|
+
"从入口文件和最大的模块看起。",
|
|
67
|
+
"先跑 ls 和 wc -l。",
|
|
68
|
+
"文件不多的话逐个读重点。",
|
|
69
|
+
"注意跳过构建产物。",
|
|
70
|
+
"行数最多的文件优先看。",
|
|
71
|
+
"收集完信息再下结论。",
|
|
72
|
+
].join("\n");
|
|
73
|
+
const T2 = [
|
|
74
|
+
"目录和行数都拿到了。",
|
|
75
|
+
"入口是 index.ts,520 行的 claude-view.ts 值得细看。",
|
|
76
|
+
"先 grep 关键函数定位,再找一下文档文件。",
|
|
77
|
+
"然后输出检查结论。",
|
|
78
|
+
].join("\n");
|
|
79
|
+
|
|
80
|
+
const M1 = { role: "assistant", timestamp: 1_700_000_000_000, provider: "anthropic", model: "demo", content: [{ type: "thinking", thinking: T1 }] };
|
|
81
|
+
const M2 = { role: "assistant", timestamp: 1_700_000_001_000, provider: "anthropic", model: "demo", content: [{ type: "thinking", thinking: T2 }] };
|
|
82
|
+
const M3 = { role: "assistant", timestamp: 1_700_000_002_000, provider: "anthropic", model: "demo", content: [{ type: "text", text: "检查完了。" }] };
|
|
83
|
+
|
|
84
|
+
const container = { children: [] as any[] };
|
|
85
|
+
const api = createClaudeView(config, () => theme, (s) => console.error("report:", s));
|
|
86
|
+
api.hookMode({ ui: { requestRender() {} } });
|
|
87
|
+
|
|
88
|
+
function snap(title: string, note?: string) {
|
|
89
|
+
const lines = api.renderChat(container, W);
|
|
90
|
+
console.log(`\n┏━━ ${title}`);
|
|
91
|
+
if (note) console.log(`┃ ${note}`);
|
|
92
|
+
console.log("┗" + "─".repeat(W + 2));
|
|
93
|
+
for (const l of lines) console.log("│" + stripStyle(l) + "│");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const user = makeUser("帮我检查一下项目结构");
|
|
97
|
+
const a1 = makeAssistant({ thinking: T1, streaming: true, msg: M1 });
|
|
98
|
+
container.children.push(user, a1);
|
|
99
|
+
|
|
100
|
+
api.onAgentStart();
|
|
101
|
+
api.onEvent({ type: "message_start", message: M1 });
|
|
102
|
+
api.onEvent({ type: "message_update", message: M1 });
|
|
103
|
+
await sleep(2100);
|
|
104
|
+
snap("快照 1 · M1 thinking 直播中", "stage 首次撑开(sticky 写入):thinking 尾窗滚动,仅显示尾部 3 行");
|
|
105
|
+
|
|
106
|
+
api.onEvent({ type: "message_end", message: M1 });
|
|
107
|
+
a1.isStreaming = false;
|
|
108
|
+
const bashDone = () => makeTool("bash", { command: "wc -l *.ts" }, {
|
|
109
|
+
result: { content: [{ type: "text", text: " 258 claude-tools.ts\n 520 claude-view.ts\n 230 index.ts\n 1008 total" }] },
|
|
110
|
+
});
|
|
111
|
+
const toolLs = makeTool("ls", { path: "." }, {
|
|
112
|
+
result: { content: [{ type: "text", text: "claude-tools.ts\nclaude-view.ts\nindex.ts\ntsconfig.json\nvendor/" }] },
|
|
113
|
+
});
|
|
114
|
+
const toolBash = makeTool("bash", { command: "wc -l *.ts" }, { partial: true });
|
|
115
|
+
container.children.push(toolLs, toolBash);
|
|
116
|
+
snap("快照 2 · M1 结束,ls 完成、bash 执行中", "【修复点】thinking 停止流式后 stage 保持展开(旧版这里会折回成 Running… 摘要行)");
|
|
117
|
+
|
|
118
|
+
const a2 = makeAssistant({ thinking: T2, streaming: true, msg: M2 });
|
|
119
|
+
container.children.push(a2);
|
|
120
|
+
api.onEvent({ type: "message_start", message: M2 });
|
|
121
|
+
api.onEvent({ type: "message_update", message: M2 });
|
|
122
|
+
snap("快照 3 · M2 thinking 又开始直播", "无跳变:stage 依旧展开,尾窗切到 thinking2;工具以折叠卡片随行");
|
|
123
|
+
|
|
124
|
+
await sleep(3100);
|
|
125
|
+
api.onEvent({ type: "message_end", message: M2 });
|
|
126
|
+
a2.isStreaming = false;
|
|
127
|
+
const idx = container.children.indexOf(toolBash);
|
|
128
|
+
container.children[idx] = bashDone();
|
|
129
|
+
const readText = Array.from({ length: 24 }, (_, i) => `${i + 1} line ${i + 1} of index.ts`).join("\n") + "\n\n[Showing lines 1-24 of 87]";
|
|
130
|
+
container.children.push(
|
|
131
|
+
makeTool("read", { path: "src/index.ts" }, { result: { content: [{ type: "text", text: readText }] } }),
|
|
132
|
+
makeTool("grep", { pattern: "runtimeOpen", path: "." }, { result: { content: [{ type: "text", text: "claude-view.ts:488:const runtimeOpen = stage.parts.some(…" }] } }),
|
|
133
|
+
makeTool("find", { pattern: "*.md" }, { partial: true }),
|
|
134
|
+
);
|
|
135
|
+
snap("快照 4 · M2 结束,read/grep 完成、find 执行中", "【修复点】thinking 间歇仍保持展开(旧版这里又折回,一下一上横跳)");
|
|
136
|
+
|
|
137
|
+
const a3 = makeAssistant({ text: "检查完了:3 个 TS 文件共约 1000 行,入口 index.ts,最重的模块是 claude-view.ts,vendor/ 是第三方 diff 渲染器。结构清晰,没有发现明显问题。", msg: M3 });
|
|
138
|
+
container.children.push(a3);
|
|
139
|
+
api.onEvent({ type: "message_start", message: M3 });
|
|
140
|
+
api.onEvent({ type: "message_end", message: M3 });
|
|
141
|
+
api.onSettled();
|
|
142
|
+
snap("快照 5 · M3 输出文本 → stage 封口瞬间", "「不能折叠进去的消息」出现:收拢动画第 0 帧(summary 行已冻结 + 成员行将开始向上收)");
|
|
143
|
+
|
|
144
|
+
await sleep(300);
|
|
145
|
+
snap("快照 6 · 收拢动画结束(最终形态)", "输出完的稳定态:一行摘要,直到用户点它或 ctrl+o 才再展开");
|
|
146
|
+
|
|
147
|
+
api.dispose();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Word sweep adapted from pi-cc-extensions' animateCompactThinkingText (MIT).
|
|
3
|
+
* Sweeps the first word of any active label — Running, Thinking, Reading,
|
|
4
|
+
* Listing, Searching, Writing, Editing — without per-verb whitelists; settled
|
|
5
|
+
* summaries never reach this function and stay static.
|
|
6
|
+
*/
|
|
7
|
+
type RunningTheme = {
|
|
8
|
+
fg(color: string, text: string): string;
|
|
9
|
+
italic?(text: string): string;
|
|
10
|
+
bold?(text: string): string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function animateRunningSummary(label: string, theme: RunningTheme, frame: number): string {
|
|
14
|
+
const match = /^(\S+)([\s\S]*)$/.exec(label);
|
|
15
|
+
if (!match) return theme.fg("muted", label);
|
|
16
|
+
const chars = Array.from(match[1]);
|
|
17
|
+
const span = Math.max(1, Math.min(5, Math.ceil(chars.length * 0.28)));
|
|
18
|
+
const start = (Math.floor(frame) % (chars.length + span)) - span;
|
|
19
|
+
const end = start + span;
|
|
20
|
+
const before = chars.slice(0, Math.max(0, start)).join("");
|
|
21
|
+
const bright = chars.slice(Math.max(0, start), Math.min(chars.length, end)).join("");
|
|
22
|
+
const after = chars.slice(Math.max(0, end)).join("");
|
|
23
|
+
const style = (text: string, color: string) => {
|
|
24
|
+
if (!text) return "";
|
|
25
|
+
const painted = theme.fg(color, text);
|
|
26
|
+
return theme.italic ? theme.italic(painted) : painted;
|
|
27
|
+
};
|
|
28
|
+
return style(before, "muted") + (bright ? style(theme.bold ? theme.bold(bright) : bright, "text") : "") + style(after, "muted") + theme.fg("muted", match[2]);
|
|
29
|
+
}
|