@yinan_chen/pi-claude-code-ui 0.0.0-stage → 0.2.1
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 +36 -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,775 @@
|
|
|
1
|
+
/** Pi-specific transcript adapter. All private component fields are isolated here. */
|
|
2
|
+
import { Box, Container, Markdown, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { getMarkdownTheme, keyText } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { BUILTIN_NAMES, PROCESS_TOOL_NAMES, bold, canExpandTool, expandHint, failedTool, fg, fit, headerOf, isBlank, isImageLine, oneLine, paintBackground, paintTool, prefixed, prefixZones, targetOf, type ToolAppearance, type ToolView } from "./claude-tools.ts";
|
|
5
|
+
import { animateRunningSummary } from "./running-effect.ts";
|
|
6
|
+
|
|
7
|
+
export interface ClaudeConfig extends ToolAppearance {
|
|
8
|
+
detailsBackground: string;
|
|
9
|
+
/** 直播 thinking 尾窗,未封口时仅保留尾部屏幕行。 */
|
|
10
|
+
liveThinking: boolean;
|
|
11
|
+
thinkingTailLines: number;
|
|
12
|
+
/** 封口自动折叠的收拢动画时长(ms);0 = 直接折。 */
|
|
13
|
+
foldAnimMs: number;
|
|
14
|
+
/** Skill 调用块样式:"native" = pi 原生 `[skill] name` 折叠行;"claude" = `● Skill(name)` + `⎿ Loaded skill`。默认 native(一南 2026-10-05)。 */
|
|
15
|
+
skillRendering: "native" | "claude";
|
|
16
|
+
}
|
|
17
|
+
interface Part {
|
|
18
|
+
key: string;
|
|
19
|
+
kind: "thought" | "text" | "tool" | "other";
|
|
20
|
+
owner: any;
|
|
21
|
+
node?: any;
|
|
22
|
+
run?: number;
|
|
23
|
+
streaming?: boolean;
|
|
24
|
+
}
|
|
25
|
+
interface Stage { key: string; parts: Part[]; sealed: boolean; }
|
|
26
|
+
interface Clock { active?: number; started?: number; elapsed: Map<number, number>; lengths: number[]; }
|
|
27
|
+
const SLOT = "__leanTidyClaude";
|
|
28
|
+
const ORIGINAL_RENDER = Symbol.for("lean-tidy/claude-original-render");
|
|
29
|
+
const partId = (o: any): string => o.__claudeId ??= `message-${(globalThis as any).__leanTidyPartId = ((globalThis as any).__leanTidyPartId ?? 0) + 1}`;
|
|
30
|
+
const isTool = (c: any): boolean => !!c && typeof c.updateDisplay === "function" && typeof c.toolName === "string";
|
|
31
|
+
const isAssistant = (c: any): boolean => !!c && typeof c.updateContent === "function" && typeof c.setHideThinkingBlock === "function";
|
|
32
|
+
const isSpacer = (c: any): boolean => !!c && typeof c.setLines === "function" && "lines" in c;
|
|
33
|
+
const isThoughtRegion = (c: any): boolean => !!c && "child" in c && typeof c.onMouse === "function";
|
|
34
|
+
const isSkill = (c: any): boolean => !!c?.skillBlock;
|
|
35
|
+
const isUserMessage = (c: any): boolean => typeof c?.rebuild === "function" && typeof c?.text === "string";
|
|
36
|
+
const SGR_RE = /\x1b\[([0-9;]*)m/g;
|
|
37
|
+
/**
|
|
38
|
+
* 原位消息泡是浅底深字,但 Pi 的 Markdown theme 与深色助手区共用:代码块、链接等
|
|
39
|
+
* 会自行写入 Markdown/syntax 前景色,并覆盖外层 userMessageText。逐个保留粗体/斜体等
|
|
40
|
+
* 装饰和背景,只把所有“改变/重置前景色”的 SGR 归一到 userMessageText。
|
|
41
|
+
*/
|
|
42
|
+
function userMessageForeground(line: string, activeTheme: any): string {
|
|
43
|
+
if (isImageLine(line)) return line;
|
|
44
|
+
let target = "";
|
|
45
|
+
try { target = activeTheme?.getFgAnsi?.("userMessageText") ?? ""; } catch {}
|
|
46
|
+
if (!target) return fg(activeTheme, "userMessageText", line);
|
|
47
|
+
const normalized = line.replace(SGR_RE, (sequence, body: string) => {
|
|
48
|
+
const params = body === "" ? [0] : body.split(";").map((value) => value === "" ? 0 : Number(value));
|
|
49
|
+
if (params.some((value) => !Number.isFinite(value))) return sequence;
|
|
50
|
+
const kept: number[] = [];
|
|
51
|
+
let touchesForeground = false;
|
|
52
|
+
for (let i = 0; i < params.length; i++) {
|
|
53
|
+
const code = params[i];
|
|
54
|
+
if (code === 0) {
|
|
55
|
+
kept.push(code);
|
|
56
|
+
touchesForeground = true;
|
|
57
|
+
} else if (code === 39 || (code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
|
|
58
|
+
touchesForeground = true;
|
|
59
|
+
} else if (code === 38) {
|
|
60
|
+
touchesForeground = true;
|
|
61
|
+
if (params[i + 1] === 5 && i + 2 < params.length) i += 2;
|
|
62
|
+
else if (params[i + 1] === 2 && i + 4 < params.length) i += 4;
|
|
63
|
+
} else kept.push(code);
|
|
64
|
+
}
|
|
65
|
+
if (!touchesForeground) return sequence;
|
|
66
|
+
return `${kept.length ? `\x1b[${kept.join(";")}m` : ""}${target}`;
|
|
67
|
+
});
|
|
68
|
+
return `${target}${normalized}\x1b[39m`;
|
|
69
|
+
}
|
|
70
|
+
const blankTrim = (lines: string[]): string[] => {
|
|
71
|
+
if (lines.some(isImageLine)) return lines; // Image height is protocol data, never whitespace.
|
|
72
|
+
let a = 0, b = lines.length;
|
|
73
|
+
while (a < b && isBlank(lines[a])) a++;
|
|
74
|
+
while (b > a && isBlank(lines[b - 1])) b--;
|
|
75
|
+
return lines.slice(a, b);
|
|
76
|
+
};
|
|
77
|
+
const duration = (ms: number): string => {
|
|
78
|
+
const s = Math.max(1, Math.round(ms / 1000));
|
|
79
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
80
|
+
};
|
|
81
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
82
|
+
/** Foreign 工具(codemode/MCP 等)折叠态 body 的物理行预算(2026-09-30 一南:3→0→1→0;codemode 有 calls 时动态放宽、pending 控件豁免,见 foreignBody)。 */
|
|
83
|
+
const FOREIGN_PREVIEW_ROWS = 0;
|
|
84
|
+
const spinnerGlyph = (): string => SPINNER[Math.floor(Date.now() / 200) % SPINNER.length];
|
|
85
|
+
function thoughtRuns(message: any): string[] {
|
|
86
|
+
const out: string[] = []; let last = false;
|
|
87
|
+
for (const c of message?.content ?? []) {
|
|
88
|
+
if (c.type === "thinking") {
|
|
89
|
+
const s = String(c.thinking ?? "");
|
|
90
|
+
if (last) out[out.length - 1] += s;
|
|
91
|
+
else { out.push(s); last = true; }
|
|
92
|
+
} else last = false;
|
|
93
|
+
}
|
|
94
|
+
return out.filter((s) => s.trim());
|
|
95
|
+
}
|
|
96
|
+
function lastThought(message: any): boolean {
|
|
97
|
+
const content = [...(message?.content ?? [])].reverse();
|
|
98
|
+
const last = content.find((c: any) => c.type === "thinking" ? String(c.thinking ?? "").trim() : c.type === "text" ? String(c.text ?? "").trim() : true);
|
|
99
|
+
return last?.type === "thinking";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createClaudeView(
|
|
103
|
+
config: ClaudeConfig,
|
|
104
|
+
getTheme: () => any,
|
|
105
|
+
report: (s: string) => void,
|
|
106
|
+
renderRichDiff?: (tool: any, expanded: boolean, theme: any, invalidate: () => void) => { render(width: number): string[] } | undefined,
|
|
107
|
+
) {
|
|
108
|
+
let disabled = false, inFlight = false, mode: any;
|
|
109
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
110
|
+
let clock: Clock = { elapsed: new Map(), lengths: [] };
|
|
111
|
+
let currentMessage: any;
|
|
112
|
+
let clockByMessage = new WeakMap<object, Map<number, number>>();
|
|
113
|
+
const clockByStamp: Map<string, Map<number, number>> = ((globalThis as any).__leanTidyThinkingTimes ??= new Map());
|
|
114
|
+
const open = new Map<string, boolean>();
|
|
115
|
+
/** 未封口阶段的 sticky 撑开(一南 2026-09-30 报的横跳 bug):一旦因直播 thinking 撑开,
|
|
116
|
+
* 就记住直到封口转变——thinking 间歇(工具执行中)不再折回,连续工具轮次的摘要行
|
|
117
|
+
* 不再反复展开/折叠。封口转变时清掉、随收拢动画折一次;ctrl+o/ctrl+T/新会话一并清。 */
|
|
118
|
+
const stageAutoOpen = new Set<string>();
|
|
119
|
+
const chatContainers = new Set<any>();
|
|
120
|
+
const renderedTools = new WeakMap<object, { key: unknown[]; lines: string[]; layout: any }>();
|
|
121
|
+
// Rich diff components cache their row layout and optional async highlighting. Keep one
|
|
122
|
+
// per tool/result/expansion so re-rendering the transcript does not restart that work.
|
|
123
|
+
const richDiffs = new WeakMap<object, { args: unknown; result: unknown; themeKey: unknown; expanded: boolean; component: { render(width: number): string[] } }>();
|
|
124
|
+
const renderedThoughts = new WeakMap<object, Map<number, { text: string; theme: string; node: any }>>();
|
|
125
|
+
const toolOverrides = new Map<string, boolean>();
|
|
126
|
+
/** 直播尾窗:点过「显示全部」的 thinking run(按 part key,本 run 内不再截尾;session_start 清空)。 */
|
|
127
|
+
const tailOff = new Set<string>();
|
|
128
|
+
/** 上一帧各 stage 封口与否(首帧 undefined 不起动画)与展开态可见行数(收拢动画的行数依据)。 */
|
|
129
|
+
const stagePrevSealed = new Map<string, boolean>();
|
|
130
|
+
const stageRows = new Map<string, number>();
|
|
131
|
+
/** 封口收拢动画:封口那刻捕获的成员行(冻结)+ 冻结文案 + 起点;进度按墙钟推进。 */
|
|
132
|
+
const stageAnim = new Map<string, { startedAt: number; lines: string[]; label: string }>();
|
|
133
|
+
const tokenKey = (): string => ["text", "muted", "dim", "success", "error", "thinkingText", "toolOutput", "toolDiffAdded", "toolDiffRemoved"].map((k) => fg(theme(), k, "x")).join("") + (theme()?.name ?? "");
|
|
134
|
+
const stamp = (m: any): string | undefined => typeof m?.timestamp === "number" ? `${m.timestamp}:${m.provider ?? ""}:${m.model ?? ""}` : undefined;
|
|
135
|
+
const theme = () => getTheme();
|
|
136
|
+
const redraw = () => mode?.ui?.requestRender?.();
|
|
137
|
+
const effectiveExpanded = (tool: any): boolean => toolOverrides.get(tool.toolCallId) ?? (tool.__claudeForcedDetail === true || !!tool.expanded);
|
|
138
|
+
function clockSnapshot(message: any): void {
|
|
139
|
+
if (!message || typeof message !== "object") return;
|
|
140
|
+
const values = new Map(clock.elapsed);
|
|
141
|
+
if (clock.active !== undefined && clock.started !== undefined) values.set(clock.active, Date.now() - clock.started);
|
|
142
|
+
clockByMessage.set(message, values);
|
|
143
|
+
const s = stamp(message);
|
|
144
|
+
if (s) {
|
|
145
|
+
clockByStamp.set(s, values);
|
|
146
|
+
if (clockByStamp.size > 2048) clockByStamp.delete(clockByStamp.keys().next().value!);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function finishThinking(): void {
|
|
150
|
+
if (clock.active !== undefined && clock.started !== undefined) clock.elapsed.set(clock.active, Date.now() - clock.started);
|
|
151
|
+
clock.active = undefined; clock.started = undefined;
|
|
152
|
+
clockSnapshot(currentMessage);
|
|
153
|
+
}
|
|
154
|
+
function observe(event: any): void {
|
|
155
|
+
const m = event?.message;
|
|
156
|
+
if (m?.role !== "assistant") return;
|
|
157
|
+
if (event.type === "message_start") { finishThinking(); clock = { elapsed: new Map(), lengths: [] }; }
|
|
158
|
+
currentMessage = m;
|
|
159
|
+
const runs = thoughtRuns(m), lengths = runs.map((s) => s.length);
|
|
160
|
+
const run = runs.length - 1;
|
|
161
|
+
const grew = run >= 0 && lengths[run] > (clock.lengths[run] ?? 0);
|
|
162
|
+
const active = event.type !== "message_end" && run >= 0 && (lastThought(m) || grew);
|
|
163
|
+
if (active && clock.active !== run && !clock.elapsed.has(run)) { finishThinking(); clock.active = run; clock.started = Date.now(); }
|
|
164
|
+
if (!active) finishThinking();
|
|
165
|
+
clock.lengths = lengths; clockSnapshot(m);
|
|
166
|
+
}
|
|
167
|
+
function msOf(p: Part): number | undefined {
|
|
168
|
+
const message = p.owner.lastMessage;
|
|
169
|
+
const values = clockByMessage.get(message) ?? (stamp(message) ? clockByStamp.get(stamp(message)!) : undefined);
|
|
170
|
+
return values?.get(p.run ?? 0);
|
|
171
|
+
}
|
|
172
|
+
function pulse(): void {
|
|
173
|
+
if (currentMessage && clock.active !== undefined) clockSnapshot(currentMessage);
|
|
174
|
+
redraw();
|
|
175
|
+
}
|
|
176
|
+
function setRunning(value: boolean): void {
|
|
177
|
+
inFlight = value;
|
|
178
|
+
if (value && !timer) { timer = setInterval(pulse, 250); timer.unref?.(); }
|
|
179
|
+
if (!value && timer) { clearInterval(timer); timer = undefined; }
|
|
180
|
+
}
|
|
181
|
+
function partsOf(child: any): Part[] {
|
|
182
|
+
if (!isAssistant(child)) return [{ key: isTool(child) ? `tool:${child.toolCallId}` : partId(child), kind: isTool(child) ? "tool" : "other", owner: child }];
|
|
183
|
+
const cc = child.contentContainer;
|
|
184
|
+
if (!Array.isArray(cc?.children) || !(child.thinkingVisibilityOverrides instanceof Map)) throw new Error("Assistant component shape changed");
|
|
185
|
+
let run = 0, pos = 0;
|
|
186
|
+
const parts: Part[] = [];
|
|
187
|
+
for (const node of cc.children) {
|
|
188
|
+
if (isSpacer(node)) continue;
|
|
189
|
+
if (isThoughtRegion(node)) {
|
|
190
|
+
parts.push({ key: `${partId(child)}:thinking:${run}`, kind: "thought", owner: child, node, run: run++, streaming: child.isStreaming });
|
|
191
|
+
} else parts.push({ key: `${partId(child)}:text:${pos++}`, kind: "text", owner: child, node });
|
|
192
|
+
}
|
|
193
|
+
return parts;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* 直播尾窗的 thinking 块:头行(⠹ spinner/∴ + Thinking 扫光 · 藏行数 · 点击全显,或未截尾时只报行数)
|
|
197
|
+
* + 尾部 cap-1 行(统一 3 列缩进,∴ 只在头行)。窗口高度恒为 thinkingTailLines,越过边界不跳行。
|
|
198
|
+
*/
|
|
199
|
+
function windowedThought(p: Part, width: number): { head: string; body: string[] } {
|
|
200
|
+
const pad = Math.max(0, Math.min(3, width - 1));
|
|
201
|
+
const originallyHidden = p.owner.thinkingVisibilityOverrides?.get(p.run) ?? p.owner.hideThinkingBlock;
|
|
202
|
+
// Pi's hidden-thinking node contains only "Thinking...". For the transient
|
|
203
|
+
// live tail, render the actual streamed text without persisting a visibility
|
|
204
|
+
// override; the user's hidden setting still applies after the stage seals.
|
|
205
|
+
const source = originallyHidden ? (thoughtRuns(p.owner.lastMessage)[p.run ?? 0] ?? "") : "";
|
|
206
|
+
const node = originallyHidden && source
|
|
207
|
+
? new Markdown(source, 0, 0, p.owner.markdownTheme ?? getMarkdownTheme(), {
|
|
208
|
+
color: (value: string) => fg(theme(), "thinkingText", value), italic: true,
|
|
209
|
+
})
|
|
210
|
+
: p.node.child;
|
|
211
|
+
const previous = node.paddingX;
|
|
212
|
+
let raw: string[];
|
|
213
|
+
try {
|
|
214
|
+
if (typeof previous === "number") node.paddingX = 0;
|
|
215
|
+
raw = blankTrim(node.render(Math.max(1, width - pad)));
|
|
216
|
+
} finally {
|
|
217
|
+
if (typeof previous === "number") node.paddingX = previous;
|
|
218
|
+
}
|
|
219
|
+
const cap = Math.max(1, config.thinkingTailLines);
|
|
220
|
+
const hidden = Math.max(0, raw.length - cap + 1);
|
|
221
|
+
const live = !!p.streaming && inFlight;
|
|
222
|
+
const spinning = live && clock.active === p.run;
|
|
223
|
+
const glyph = spinning ? fg(theme(), "accent", spinnerGlyph()) : fg(theme(), "thinkingText", "∴");
|
|
224
|
+
const word = live ? animateRunningSummary("Thinking", theme(), Math.floor(Date.now() / 90)) : fg(theme(), "thinkingText", "Thinking");
|
|
225
|
+
const meta = fg(theme(), "dim", hidden > 0 ? `${hidden} earlier ${hidden === 1 ? "line" : "lines"} · click to show all` : `${raw.length} ${raw.length === 1 ? "line" : "lines"}`);
|
|
226
|
+
const head = fit(` ${glyph} ${word} · ${meta}`, width);
|
|
227
|
+
const gutter = truncateToWidth(" ", pad, "");
|
|
228
|
+
const body = raw.slice(hidden).map((l: string) => (isImageLine(l) ? l : fit(gutter + l, width)));
|
|
229
|
+
return { head, body };
|
|
230
|
+
}
|
|
231
|
+
function nativePart(p: Part, width: number, forceThought = false): { lines: string[]; component: any } {
|
|
232
|
+
if (p.kind === "tool" || p.kind === "other") {
|
|
233
|
+
const owner = p.owner;
|
|
234
|
+
const before = owner.__claudeForcedDetail;
|
|
235
|
+
if (forceThought && p.kind === "tool") owner.__claudeForcedDetail = true;
|
|
236
|
+
try { return { lines: owner.render(width), component: owner }; }
|
|
237
|
+
finally { owner.__claudeForcedDetail = before; }
|
|
238
|
+
}
|
|
239
|
+
const isThought = p.kind === "thought";
|
|
240
|
+
const hidden = p.owner.thinkingVisibilityOverrides?.get(p.run) ?? p.owner.hideThinkingBlock;
|
|
241
|
+
if (isThought && hidden && !forceThought) return { lines: [], component: {} };
|
|
242
|
+
const pad = Math.max(0, Math.min(3, width - 1));
|
|
243
|
+
let node = isThought ? p.node.child : p.node;
|
|
244
|
+
if (isThought && forceThought && !("defaultTextStyle" in node)) {
|
|
245
|
+
const text = thoughtRuns(p.owner.lastMessage)[p.run ?? 0] ?? "", fingerprint = tokenKey();
|
|
246
|
+
let byRun = renderedThoughts.get(p.owner);
|
|
247
|
+
if (!byRun) { byRun = new Map(); renderedThoughts.set(p.owner, byRun); }
|
|
248
|
+
let cached = byRun.get(p.run ?? 0);
|
|
249
|
+
if (!cached || cached.text !== text || cached.theme !== fingerprint) {
|
|
250
|
+
cached = { text, theme: fingerprint, node: new Markdown(text, 0, 0, p.owner.markdownTheme ?? getMarkdownTheme(), { color: (s: string) => fg(theme(), "thinkingText", s), italic: true }) };
|
|
251
|
+
byRun.set(p.run ?? 0, cached);
|
|
252
|
+
}
|
|
253
|
+
node = cached.node;
|
|
254
|
+
}
|
|
255
|
+
const previous = node.paddingX;
|
|
256
|
+
let raw: string[];
|
|
257
|
+
try { if (typeof previous === "number") node.paddingX = 0; raw = blankTrim(node.render(Math.max(1, width - pad))); }
|
|
258
|
+
finally { if (typeof previous === "number") node.paddingX = previous; }
|
|
259
|
+
const prefix = isThought ? ` ${fg(theme(), "thinkingText", "∴")} ` : ` ${fg(theme(), "text", "●")} `;
|
|
260
|
+
const lines = raw.map((l, i) => isImageLine(l) ? l : fit(truncateToWidth(i ? " " : prefix, pad, "") + l, width));
|
|
261
|
+
const component = {
|
|
262
|
+
handleMouse(e: any) {
|
|
263
|
+
if (e.x < pad) {
|
|
264
|
+
if (isThought && e.type === "click" && e.button === "left") {
|
|
265
|
+
p.owner.thinkingVisibilityOverrides.set(p.run, true); p.owner.updateContent(p.owner.lastMessage); redraw(); return { handled: true };
|
|
266
|
+
}
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
return node.handleMouse?.({ ...e, x: e.x - pad, width: Math.max(1, width - pad) });
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
return { lines, component };
|
|
273
|
+
}
|
|
274
|
+
function toolView(self: any): ToolView {
|
|
275
|
+
return { name: self.toolName, args: self.args ?? {}, result: self.result, partial: !!self.isPartial, started: !!self.executionStarted, expanded: effectiveExpanded(self), cwd: self.cwd };
|
|
276
|
+
}
|
|
277
|
+
function toggleTool(self: any): any {
|
|
278
|
+
return { handleMouse(e: any) {
|
|
279
|
+
if (e.type !== "click" || e.button !== "left" || !canExpandTool(toolView(self))) return undefined;
|
|
280
|
+
const next = !(self.__claudeRenderedExpanded ?? effectiveExpanded(self));
|
|
281
|
+
toolOverrides.set(self.toolCallId, next); self.setExpanded(next); redraw();
|
|
282
|
+
return { handled: true };
|
|
283
|
+
} };
|
|
284
|
+
}
|
|
285
|
+
function foreignBody(self: any, width: number, expanded: boolean): { lines: string[]; entries: any[] } | undefined {
|
|
286
|
+
const shell = self.getRenderShell?.() === "self" ? self.selfRenderContainer : self.contentBox;
|
|
287
|
+
// codemode 的脚本(call 组件)平时由头行参数提示代言;展开时才随结果一起渲染。
|
|
288
|
+
const withCall = self.toolName === "codemode" && expanded && self.result;
|
|
289
|
+
const nativeChildren: any[] = Array.isArray(shell?.children)
|
|
290
|
+
? (self.result ? shell.children.slice(withCall ? 0 : 1) : shell.children.slice(0, 1))
|
|
291
|
+
: [];
|
|
292
|
+
if (!nativeChildren.length) return undefined;
|
|
293
|
+
const indent = Math.min(6, Math.max(0, width - 1)), bodyWidth = Math.max(1, width - indent);
|
|
294
|
+
const lines: string[] = [], entries: any[] = [];
|
|
295
|
+
// pending(无 result)的 call 组件是待交互控件(确认按钮等),折叠它会死锁:
|
|
296
|
+
// 用户必须先看到并点它,工具才会有 result。故预算只约束已结算的 body。
|
|
297
|
+
let rowsLeft = expanded || !self.result ? Infinity : FOREIGN_PREVIEW_ROWS, hidden = 0;
|
|
298
|
+
// codemode(一南 2026-09-30):折叠预算随嵌套调用数放宽——pi renderResult 的 calls 段
|
|
299
|
+
// (✓/✗ 行、"... (N earlier calls)"、费用汇总)整段显示,仅其后的 stdout 段折叠进 hint。
|
|
300
|
+
const cmCalls = self.toolName === "codemode" && self.result && Array.isArray(self.result?.details?.calls)
|
|
301
|
+
? self.result.details.calls.length
|
|
302
|
+
: 0;
|
|
303
|
+
for (const native of nativeChildren) {
|
|
304
|
+
if (!native || typeof native.render !== "function") continue;
|
|
305
|
+
// pi 的 renderResult 以 "\n" 开头分段;剥掉首尾空行,别让 ⎿ 拐角行空挂。
|
|
306
|
+
let raw: string[] = blankTrim(native.render(bodyWidth));
|
|
307
|
+
if (cmCalls > 0 && rowsLeft !== Infinity) {
|
|
308
|
+
// calls 段与 stdout 段以一个分段空行相隔(pi renderResult 用 join("\n\n") 分段)。
|
|
309
|
+
const sep = raw.findIndex(isBlank);
|
|
310
|
+
const callRows = sep === -1 ? raw.length : sep;
|
|
311
|
+
if (callRows > rowsLeft) rowsLeft = callRows;
|
|
312
|
+
}
|
|
313
|
+
if (raw.length > rowsLeft) { hidden += raw.length - rowsLeft; raw = raw.slice(0, Math.max(0, rowsLeft)); }
|
|
314
|
+
rowsLeft -= raw.length;
|
|
315
|
+
const start = lines.length;
|
|
316
|
+
for (let i = 0; i < raw.length; i++) lines.push(isImageLine(raw[i]) ? raw[i] : fit(truncateToWidth(start + i === 0 ? ` ${fg(theme(), "dim", "⎿")} ` : " ", indent, "") + raw[i], width));
|
|
317
|
+
const proxy = {
|
|
318
|
+
handleMouse(e: any) {
|
|
319
|
+
if (e.x < indent) return undefined;
|
|
320
|
+
const local = { ...e, x: e.x - indent, width: bodyWidth, height: raw.length };
|
|
321
|
+
const result = native.handleMouse?.(local);
|
|
322
|
+
if (result && (result.handled || result.capture || result.focus || "target" in result)) {
|
|
323
|
+
// pi 的 MouseRegion 在 click 时会自行 toggle self.expanded;把我们的 override
|
|
324
|
+
// 对齐到 toggle 后的新状态,避免视图展开态与组件渲染态脱钩(混合视图 bug)。
|
|
325
|
+
toolOverrides.set(self.toolCallId, !!self.expanded);
|
|
326
|
+
if ("target" in result) return result;
|
|
327
|
+
return { ...result, handled: true, ...(result.focus ? { focusTarget: native } : {}), target: { component: native, originX: e.screenX - local.x, originY: e.screenY - local.y, width: bodyWidth, height: raw.length } };
|
|
328
|
+
}
|
|
329
|
+
// The native component left this click unhandled: toggle the entry so
|
|
330
|
+
// content clicks expand/collapse foreign tools too. Controls that do
|
|
331
|
+
// handle the event (buttons, capture, focus) always win.
|
|
332
|
+
if (e.type === "click" && e.button === "left" && !isImageLine(raw[e.y] ?? "")) {
|
|
333
|
+
return toggleTool(self).handleMouse(e);
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
entries.push({ component: proxy, height: raw.length });
|
|
339
|
+
}
|
|
340
|
+
if (hidden > 0) {
|
|
341
|
+
// 截断点常停在段落分隔空行上(pi 的 renderResult 用 "\n\n" 分段):
|
|
342
|
+
// 把已显示行尾部的空行并入 hidden,让提示行紧跟内容行。
|
|
343
|
+
while (lines.length && isBlank(lines.at(-1)!)) {
|
|
344
|
+
lines.pop();
|
|
345
|
+
hidden++;
|
|
346
|
+
const last = entries.at(-1);
|
|
347
|
+
if (last && --last.height <= 0) entries.pop();
|
|
348
|
+
}
|
|
349
|
+
const more = prefixed(fg(theme(), "dim", `… +${hidden} lines`) + expandHint(theme()), width, ` ${fg(theme(), "dim", "⎿")} `, " ");
|
|
350
|
+
lines.push(...more);
|
|
351
|
+
entries.push({ component: toggleTool(self), height: more.length });
|
|
352
|
+
}
|
|
353
|
+
return lines.length ? { lines, entries } : undefined;
|
|
354
|
+
}
|
|
355
|
+
function renderTool(self: any, orig: Function, width: number): string[] {
|
|
356
|
+
if (disabled) return orig.call(self, width);
|
|
357
|
+
if (typeof self.setExpanded !== "function" || !Array.isArray(self.imageComponents)) throw new Error("Tool component shape changed");
|
|
358
|
+
if (self.hideComponent && !self.result && !self.isPartial) return [];
|
|
359
|
+
const v = toolView(self), t = theme();
|
|
360
|
+
self.__claudeRenderedExpanded = v.expanded;
|
|
361
|
+
const useNative = !BUILTIN_NAMES.has(v.name) && !!(self.resultRendererComponent ?? self.callRendererComponent);
|
|
362
|
+
const cacheKey = [width, self.args, self.result, self.isPartial, self.executionStarted, v.expanded, self.showImages, self.imageWidthCells, self.imageComponents, tokenKey(), config.bashPreviewLines, config.codePreviewLines, config.errorPreviewLines];
|
|
363
|
+
const cached = renderedTools.get(self);
|
|
364
|
+
if (!useNative && cached && cacheKey.every((x, i) => x === cached.key[i])) { self.mouseLayout = cached.layout; return cached.lines; }
|
|
365
|
+
let lines: string[], entries: any[];
|
|
366
|
+
if (useNative) {
|
|
367
|
+
const head = headerOf(v, t, width), body = foreignBody(self, width, v.expanded);
|
|
368
|
+
lines = [...head, ...(body?.lines ?? [])];
|
|
369
|
+
entries = [{ component: toggleTool(self), height: head.length }, ...(body?.entries ?? [])];
|
|
370
|
+
if (v.partial && !body?.lines.length) {
|
|
371
|
+
const pending = prefixed(fg(t, "dim", v.started ? "Running…" : "Waiting…"), width, ` ${fg(t, "dim", "⎿")} `, " ");
|
|
372
|
+
lines.push(...pending); entries.push({ component: {}, height: pending.length });
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
const painted = paintTool(v, t, width, config);
|
|
376
|
+
lines = painted.lines;
|
|
377
|
+
if ((v.name === "edit" || v.name === "write") && v.result && !failedTool(v) && renderRichDiff) {
|
|
378
|
+
const previous = richDiffs.get(self);
|
|
379
|
+
const same = previous && previous.args === self.args && previous.result === self.result
|
|
380
|
+
&& previous.themeKey === cacheKey[9] && previous.expanded === v.expanded;
|
|
381
|
+
const rich = same ? previous.component : renderRichDiff(self, v.expanded, t, () => {
|
|
382
|
+
renderedTools.delete(self);
|
|
383
|
+
redraw();
|
|
384
|
+
});
|
|
385
|
+
if (rich) {
|
|
386
|
+
if (!same) richDiffs.set(self, { args: self.args, result: self.result, themeKey: cacheKey[9], expanded: v.expanded, component: rich });
|
|
387
|
+
try {
|
|
388
|
+
const bodyWidth = Math.max(1, width - 6);
|
|
389
|
+
const detail = rich.render(bodyWidth);
|
|
390
|
+
// The vendored renderer limits body rows and supplies its own remainder hint.
|
|
391
|
+
// Do not also slice by codePreviewLines: that counts the diff header/frame
|
|
392
|
+
// against the body and hides almost every changed row.
|
|
393
|
+
const prefix = ` ${fg(t, "dim", "⎿")} `;
|
|
394
|
+
// The rich edit header already reports +N/-M; do not repeat
|
|
395
|
+
// paintTool's "Added N lines, removed M lines" summary above it.
|
|
396
|
+
const introRows = painted.headerHeight + (v.name === "edit" ? 0 : 1);
|
|
397
|
+
lines = [...painted.lines.slice(0, introRows),
|
|
398
|
+
...detail.map((line, i) => fit((i === 0 ? prefix : " ") + line, width))];
|
|
399
|
+
} catch {
|
|
400
|
+
// Keep the existing edit/write renderer as a fail-open fallback.
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// The whole painted body folds back on click; image protocol rows stay inert.
|
|
405
|
+
entries = [];
|
|
406
|
+
let start = 0;
|
|
407
|
+
const flush = (end: number) => { if (end > start) entries.push({ component: toggleTool(self), height: end - start }); };
|
|
408
|
+
for (let i = 0; i < lines.length; i++) {
|
|
409
|
+
if (isImageLine(lines[i])) { flush(i); entries.push({ component: {}, height: 1 }); start = i + 1; }
|
|
410
|
+
}
|
|
411
|
+
flush(lines.length);
|
|
412
|
+
}
|
|
413
|
+
for (const img of self.imageComponents ?? []) {
|
|
414
|
+
const raw = img.render(width); lines.push(...raw); entries.push({ component: {}, height: raw.length });
|
|
415
|
+
}
|
|
416
|
+
self.mouseLayout = { width, children: entries };
|
|
417
|
+
if (!useNative) renderedTools.set(self, { key: cacheKey, lines, layout: self.mouseLayout });
|
|
418
|
+
return lines;
|
|
419
|
+
}
|
|
420
|
+
function isMember(p: Part): boolean { return p.kind === "thought" || p.kind === "tool" && PROCESS_TOOL_NAMES.has(p.owner.toolName); }
|
|
421
|
+
function stageSummary(stage: Stage, active: boolean): string {
|
|
422
|
+
const counts = new Map<string, number>(); let ms = 0, measured = false, thought = false, errors = 0;
|
|
423
|
+
for (const p of stage.parts) {
|
|
424
|
+
if (p.kind === "thought") { thought = true; const n = msOf(p); if (n !== undefined) { ms += n; measured = true; } continue; }
|
|
425
|
+
const v = toolView(p.owner);
|
|
426
|
+
counts.set(v.name, (counts.get(v.name) ?? 0) + 1);
|
|
427
|
+
if (v.result?.isError) errors++;
|
|
428
|
+
}
|
|
429
|
+
const labels: Record<string, [string, string, string, string?]> = {
|
|
430
|
+
read: ["reading", "read", "file"], ls: ["listing", "listed", "directory", "directories"], bash: ["running", "ran", "shell command"], powershell: ["running", "ran", "PowerShell command"], grep: ["searching", "searched", "pattern"], find: ["searching", "searched", "file pattern"], write: ["writing", "wrote", "file"], edit: ["editing", "edited", "file"],
|
|
431
|
+
};
|
|
432
|
+
const items: string[] = [];
|
|
433
|
+
if (thought && (!active || counts.size === 0)) items.push(measured ? `thought for ${bold(theme(), duration(ms))}` : active ? "thinking" : "thought");
|
|
434
|
+
for (const [name, n] of counts) {
|
|
435
|
+
const l = labels[name] ?? ["using", "used", name];
|
|
436
|
+
items.push(`${l[active ? 0 : 1]} ${bold(theme(), String(n))} ${n === 1 ? l[2] : l[3] ?? l[2] + "s"}`);
|
|
437
|
+
}
|
|
438
|
+
if (errors) items.push(fg(theme(), "error", `${errors} failed`));
|
|
439
|
+
let s = items.join(", "); if (s) s = s[0].toUpperCase() + s.slice(1);
|
|
440
|
+
return s + (active ? "…" : "");
|
|
441
|
+
}
|
|
442
|
+
function renderChat(container: any, width: number): string[] {
|
|
443
|
+
if (disabled) return Container.prototype.render.call(container, width);
|
|
444
|
+
// Pi emits SkillInvocation before the user message it accompanies; the
|
|
445
|
+
// transcript shows the user's prompt first and the skill right below it.
|
|
446
|
+
const ordered: any[] = [];
|
|
447
|
+
const raw = container.children.filter((c: any) => !isSpacer(c));
|
|
448
|
+
for (let i = 0; i < raw.length; i++) {
|
|
449
|
+
if (i + 1 < raw.length && isSkill(raw[i]) && isUserMessage(raw[i + 1])) {
|
|
450
|
+
ordered.push(raw[i + 1], raw[i]);
|
|
451
|
+
i++;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
ordered.push(raw[i]);
|
|
455
|
+
}
|
|
456
|
+
const parts: Part[] = [];
|
|
457
|
+
for (const child of ordered) parts.push(...partsOf(child));
|
|
458
|
+
const units: (Part | Stage)[] = []; let current: Stage | undefined;
|
|
459
|
+
for (const p of parts) {
|
|
460
|
+
if (isMember(p)) { if (!current) { current = { key: p.key, parts: [], sealed: false }; units.push(current); } current.parts.push(p); }
|
|
461
|
+
else { if (current) current.sealed = true; current = undefined; units.push(p); }
|
|
462
|
+
}
|
|
463
|
+
const lines: string[] = [], layout: any[] = [];
|
|
464
|
+
/** 块间呼吸(一南 2026-09-26 二次修订,全面取代 2026-09-21 零空行原则):每个非空块
|
|
465
|
+
* 渲染前垫一行,块内部结构不变。同一逻辑块的两截(尾窗头行+正文、收拢动画捕获行)
|
|
466
|
+
* 传 pad=false 紧贴;首块前不垫、块尾自空行不叠加。 */
|
|
467
|
+
const append = (raw: string[], component: any, background = false, pad = true) => {
|
|
468
|
+
if (!raw.length) { layout.push({ component, height: 0 }); return; }
|
|
469
|
+
if (pad && lines.length && lines[lines.length - 1].trim() !== "") {
|
|
470
|
+
lines.push("");
|
|
471
|
+
layout.push({ component: {}, height: 1 });
|
|
472
|
+
}
|
|
473
|
+
lines.push(...(background ? raw.map((l) => paintBackground(l, width, theme(), config.detailsBackground)) : raw));
|
|
474
|
+
layout.push({ component, height: raw.length });
|
|
475
|
+
};
|
|
476
|
+
for (const unit of units) {
|
|
477
|
+
if (!("parts" in unit)) {
|
|
478
|
+
const out = nativePart(unit, width);
|
|
479
|
+
append(out.lines, out.component);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const stage = unit;
|
|
483
|
+
const tools = stage.parts.filter((p) => p.kind === "tool");
|
|
484
|
+
const pending = tools.some((p) => p.owner.isPartial);
|
|
485
|
+
const active = pending || (!stage.sealed && inFlight);
|
|
486
|
+
// 展开来源两分;灰底(detailsBackground)只跟「封口后的展开」走(一南 2026-09-21,Q12 修订):
|
|
487
|
+
// manualDefault —— 持久意图:ctrl+o(成员工具 expanded)、ctrl+T(__claudeReveal,
|
|
488
|
+
// 见 modeUpdateThinkingVisibility)。未封口阶段无灰;封口后保持展开并转灰。
|
|
489
|
+
// runtimeOpen —— 直播撑开(sticky,见 stageAutoOpen):阶段未封口且 thinking 可见,
|
|
490
|
+
// 一旦成立记忆到封口(2026-09-30 修复横跳)。封口前的点击展开同属运行时
|
|
491
|
+
// 视图(无灰),封口转变时随 open 一起清掉、走收拢动画折回。全局"显示 thinking"
|
|
492
|
+
// (hideThinkingBlock=false)不是保持展开的理由(一南 2026-09-20 报的 bug);
|
|
493
|
+
// 撑开封口阶段的只有 ctrl+T 那一刻打的标。
|
|
494
|
+
let manualDefault = false;
|
|
495
|
+
const liveNow = stage.parts.some((p) => {
|
|
496
|
+
if (p.kind === "tool") {
|
|
497
|
+
if (p.owner.expanded) manualDefault = true;
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
if (p.owner.__claudeReveal === true) {
|
|
501
|
+
manualDefault = true;
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
// Hidden thinking opens only when an actual thinking message is streaming.
|
|
505
|
+
// A historical/settled hidden block, or a pending turn without any
|
|
506
|
+
// thinking text, must keep its existing compact summary behavior.
|
|
507
|
+
const hidden = p.owner.thinkingVisibilityOverrides?.get(p.run) ?? p.owner.hideThinkingBlock;
|
|
508
|
+
return !stage.sealed && (!hidden || (config.liveThinking && inFlight && p.streaming && !!thoughtRuns(p.owner.lastMessage)[p.run ?? 0]?.trim()));
|
|
509
|
+
});
|
|
510
|
+
// ── sticky 撑开(一南 2026-09-30):本帧有可见/直播 thinking 即记入 stageAutoOpen,
|
|
511
|
+
// 未封口期间持续生效——streaming 停了也不折回,封口转变才随 open 一起清、走
|
|
512
|
+
// 收拢动画折一次。用户点击(open,含手动折叠)优先级仍在 sticky 之上。
|
|
513
|
+
if (liveNow && !stage.sealed) stageAutoOpen.add(stage.key);
|
|
514
|
+
// ── 封口转变(Q12):未封口期间点的 open=true 属运行时视图,随封口折回(清掉再走
|
|
515
|
+
// 收拢动画);ctrl+o/ctrl+T 撑住的不折、封口后转灰。历史首帧(undefined)不算转变。
|
|
516
|
+
const prevSealed = stagePrevSealed.get(stage.key);
|
|
517
|
+
stagePrevSealed.set(stage.key, stage.sealed);
|
|
518
|
+
const sealedNow = stage.sealed && prevSealed === false;
|
|
519
|
+
if (sealedNow) {
|
|
520
|
+
if (open.get(stage.key) === true) open.delete(stage.key);
|
|
521
|
+
stageAutoOpen.delete(stage.key);
|
|
522
|
+
}
|
|
523
|
+
const openOverride = open.get(stage.key);
|
|
524
|
+
const runtimeOpen = !stage.sealed && stageAutoOpen.has(stage.key);
|
|
525
|
+
const expanded = openOverride ?? (manualDefault || runtimeOpen);
|
|
526
|
+
// 灰底条件:封口后的展开——封口后点的 open=true,或封口时仍在的持久意图(ctrl+o/ctrl+T)。
|
|
527
|
+
// 未封口期间的一切展开(自动撑开、点击撑开)都是运行时视图,不上灰。
|
|
528
|
+
const manual = stage.sealed && (openOverride === true || (openOverride === undefined && manualDefault));
|
|
529
|
+
const toggle = { handleMouse(e: any) { if (e.type !== "click" || e.button !== "left") return undefined; open.set(stage.key, !expanded); stageAnim.delete(stage.key); redraw(); return { handled: true }; } };
|
|
530
|
+
// 直播尾窗(一南 2026-09-21,语义对齐 legacy):未封口阶段的 thinking 成员按
|
|
531
|
+
// thinkingTailLines 截尾滚动;头行恒占 1 行(点它 = 本 run 全显不回截尾,新 run 重新截尾)。
|
|
532
|
+
// 历史/回合结束后的未封口滞留阶段同按窗口处理——否则 settle 一刻尾窗会弹回全文。
|
|
533
|
+
const windowOn = !stage.sealed && config.liveThinking && config.thinkingTailLines > 0;
|
|
534
|
+
// ── 封口收拢动画:只在「运行时展开 → 封口」的转变上起一次(一南 Q5/Q6)。ctrl+o/ctrl+T
|
|
535
|
+
// 撑住的、历史首帧、foldAnimMs=0、上一帧没有可见行 ⇒ 都不起。
|
|
536
|
+
if (sealedNow && openOverride === undefined && !manualDefault) {
|
|
537
|
+
if (config.foldAnimMs > 0 && (stageRows.get(stage.key) ?? 0) > 0) {
|
|
538
|
+
// 捕获封口那刻的可见成员行(thinking 按尾窗语义冻结),文案冻结在封口起点。
|
|
539
|
+
const captureWindow = config.liveThinking && config.thinkingTailLines > 0;
|
|
540
|
+
const capture: string[] = [];
|
|
541
|
+
for (const p of stage.parts) {
|
|
542
|
+
if (captureWindow && p.kind === "thought" && !tailOff.has(p.key)) {
|
|
543
|
+
const w = windowedThought(p, width);
|
|
544
|
+
capture.push(w.head, ...w.body);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const out = nativePart(p, width, p.kind === "thought" || !!mode?.toolOutputExpanded);
|
|
548
|
+
capture.push(...out.lines);
|
|
549
|
+
}
|
|
550
|
+
if (capture.length) {
|
|
551
|
+
stageAnim.set(stage.key, { startedAt: Date.now(), lines: capture, label: stageSummary(stage, false) });
|
|
552
|
+
for (let i = 1; i <= 3; i++) {
|
|
553
|
+
const tick = setTimeout(() => { if (stageAnim.has(stage.key)) redraw(); }, Math.round((config.foldAnimMs * i) / 3));
|
|
554
|
+
(tick as any).unref?.();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
stageRows.delete(stage.key);
|
|
560
|
+
if (expanded) {
|
|
561
|
+
let total = 0;
|
|
562
|
+
for (const p of stage.parts) {
|
|
563
|
+
const force = open.get(stage.key) === true || !!mode?.toolOutputExpanded;
|
|
564
|
+
const w = windowOn && p.kind === "thought" && !tailOff.has(p.key) ? windowedThought(p, width) : undefined;
|
|
565
|
+
if (w) {
|
|
566
|
+
const headToggle = { handleMouse(e: any) { if (e.type !== "click" || e.button !== "left") return undefined; tailOff.add(p.key); redraw(); return { handled: true }; } };
|
|
567
|
+
append([w.head], headToggle, manual);
|
|
568
|
+
total += 1;
|
|
569
|
+
if (w.body.length) {
|
|
570
|
+
const bodyToggle = { handleMouse(e: any) { if (e.type !== "click" || e.button !== "left" || isImageLine(w.body[e.y] ?? "")) return undefined; return toggle.handleMouse(e); } };
|
|
571
|
+
append(w.body, bodyToggle, manual, false);
|
|
572
|
+
total += w.body.length;
|
|
573
|
+
}
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const out = nativePart(p, width, force || p.kind === "thought");
|
|
577
|
+
if (!out.lines.length) continue;
|
|
578
|
+
const blockToggle = {
|
|
579
|
+
handleMouse(e: any) {
|
|
580
|
+
if (e.type !== "click" || e.button !== "left" || isImageLine(out.lines[e.y] ?? "")) return undefined;
|
|
581
|
+
return toggle.handleMouse(e);
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
append(out.lines, blockToggle, manual);
|
|
585
|
+
total += out.lines.length;
|
|
586
|
+
}
|
|
587
|
+
stageRows.set(stage.key, total);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
// ── 收拢动画帧:summary 行(文案冻结)+ 捕获成员行从下往上收到 0;进度按墙钟,
|
|
591
|
+
// 帧晚到只少收几次、不会卡半截。点任意行 = 取消动画、按点击语义即时落定(一南 Q7)。
|
|
592
|
+
const anim = stageAnim.get(stage.key);
|
|
593
|
+
if (anim && anim.lines.length) {
|
|
594
|
+
const t = (Date.now() - anim.startedAt) / config.foldAnimMs;
|
|
595
|
+
const k = t >= 1 ? 0 : Math.max(1, Math.round(anim.lines.length * (1 - t)));
|
|
596
|
+
if (k > 0) {
|
|
597
|
+
append(prefixed(fg(theme(), "muted", anim.label), width, ` ${fg(theme(), "muted", " ")} `, " "), toggle);
|
|
598
|
+
const restToggle = { handleMouse(e: any) { if (e.type !== "click" || e.button !== "left" || isImageLine(anim.lines[e.y] ?? "")) return undefined; return toggle.handleMouse(e); } };
|
|
599
|
+
append(anim.lines.slice(0, k), restToggle, false, false);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
stageAnim.delete(stage.key);
|
|
603
|
+
}
|
|
604
|
+
// Pure live thinking is already represented in the existing working indicator.
|
|
605
|
+
if (active && tools.length === 0) continue;
|
|
606
|
+
const dot = active ? (Math.floor(Date.now() / 500) % 2 ? "●" : " ") : " ";
|
|
607
|
+
const label = stageSummary(stage, active);
|
|
608
|
+
const animatedLabel = active ? animateRunningSummary(label, theme(), Math.floor(Date.now() / 90)) : fg(theme(), "muted", label);
|
|
609
|
+
const rows = prefixed(animatedLabel, width, ` ${fg(theme(), "muted", dot)} `, " ");
|
|
610
|
+
if (active && tools.length) {
|
|
611
|
+
const focus = [...tools].reverse().find((p) => p.owner.isPartial) ?? tools.at(-1)!;
|
|
612
|
+
const v = toolView(focus.owner), target = targetOf(v.name, v.args, v.cwd).text;
|
|
613
|
+
if (target) rows.push(...prefixed(fg(theme(), "muted", (v.name === "bash" || v.name === "powershell" ? "$ " : "") + target), width, ` ${fg(theme(), "dim", "⎿")} `, " ").slice(0, 2));
|
|
614
|
+
}
|
|
615
|
+
append(rows, toggle);
|
|
616
|
+
// Failures stay folded like the rest: the summary counts them ("N failed")
|
|
617
|
+
// and the full error is only visible in the expanded detail region.
|
|
618
|
+
}
|
|
619
|
+
container.mouseLayout = { width, children: layout };
|
|
620
|
+
return prefixZones(lines.map((l) => fit(l, width)));
|
|
621
|
+
}
|
|
622
|
+
function hookMode(next: any): void {
|
|
623
|
+
mode = next;
|
|
624
|
+
const cc = mode?.chatContainer;
|
|
625
|
+
if (!cc || !Array.isArray(cc.children)) return;
|
|
626
|
+
if (disabled) return;
|
|
627
|
+
if (cc.__tidyNoSpacers) {
|
|
628
|
+
// Retire the previous profile's instance hooks on a live /reload.
|
|
629
|
+
if (cc.addChild?.name === "leanTidyAddChild") cc.addChild = Container.prototype.addChild;
|
|
630
|
+
for (const child of cc.children) if (isSpacer(child) && child.lines === 0) child.setLines(1);
|
|
631
|
+
delete cc.__tidyNoSpacers;
|
|
632
|
+
}
|
|
633
|
+
chatContainers.add(cc);
|
|
634
|
+
if (!cc.__claudeChatWrapped) {
|
|
635
|
+
cc.__claudeChatWrapped = true;
|
|
636
|
+
cc[ORIGINAL_RENDER] = cc.render?.name === "leanTidyChatRender" ? Container.prototype.render : cc.render;
|
|
637
|
+
chatContainers.add(cc);
|
|
638
|
+
cc.render = function (width: number) {
|
|
639
|
+
const current = (globalThis as any)[SLOT];
|
|
640
|
+
const fallback = this[ORIGINAL_RENDER] ?? Container.prototype.render;
|
|
641
|
+
if (!current || !current.enabled()) return fallback.call(this, width);
|
|
642
|
+
try { return current.renderChat(this, width); }
|
|
643
|
+
catch (e) { current.fail(e); return fallback.call(this, width); }
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function renderAssistant(self: any, orig: Function, width: number): string[] {
|
|
648
|
+
if (disabled) return orig.call(self, width);
|
|
649
|
+
// 同一条消息内部的成员紧邻(零空行)。
|
|
650
|
+
const lines: string[] = [], layout: any[] = [];
|
|
651
|
+
for (const part of partsOf(self)) {
|
|
652
|
+
const out = nativePart(part, width);
|
|
653
|
+
lines.push(...out.lines); layout.push({ component: out.component, height: out.lines.length });
|
|
654
|
+
}
|
|
655
|
+
self.mouseLayout = { width, children: layout };
|
|
656
|
+
return prefixZones(lines);
|
|
657
|
+
}
|
|
658
|
+
function renderGeneric(self: any, orig: Function, width: number): string[] {
|
|
659
|
+
if (disabled) return orig.call(self, width);
|
|
660
|
+
// User Markdown is rendered without its full-width Box. Preserve its transforms and theme.
|
|
661
|
+
// pi ≤0.99 把 Markdown 包在 Box 里(children[0].children[0]);1.0.0 修「每行保留两份
|
|
662
|
+
// 全宽副本」时删掉了 Box,Markdown 直接落在 children[0](user-message.js rebuild)。
|
|
663
|
+
// 两种形状都取最内层那个 Markdown;Markdown 自身无 children,?? 回退不会误取。
|
|
664
|
+
const userMarkdown = self.children?.[0]?.children?.[0] ?? self.children?.[0];
|
|
665
|
+
if (typeof self.rebuild === "function" && typeof self.text === "string" && typeof userMarkdown?.render === "function") {
|
|
666
|
+
const markdown = userMarkdown;
|
|
667
|
+
const pad = Math.min(3, Math.max(0, width - 1));
|
|
668
|
+
// pi 1.0.0 的 Markdown 自带 paddingX(旧版缩进在已删除的 Box 上);❯ 前缀自己提供
|
|
669
|
+
// 缩进,渲染前临时置零(同 thinking 节点的手法),否则 ❯ 后会出现双空格。
|
|
670
|
+
// Markdown.render 的行缓存不含 paddingX,恢复后 invalidate 防止零缩进结果
|
|
671
|
+
// 污染后续原生渲染。
|
|
672
|
+
const previousPad = markdown.paddingX;
|
|
673
|
+
let raw: string[];
|
|
674
|
+
try { if (typeof previousPad === "number") markdown.paddingX = 0; raw = blankTrim(markdown.render(Math.max(1, width - pad))); }
|
|
675
|
+
finally { if (typeof previousPad === "number") { markdown.paddingX = previousPad; markdown.invalidate?.(); } }
|
|
676
|
+
const lines = raw.map((l: string, i: number) => isImageLine(l) ? l : fit((i ? " " : " ❯ ").slice(0, pad) + l, width));
|
|
677
|
+
const activeTheme = theme();
|
|
678
|
+
const themed = lines.map((l: string) => paintBackground(userMessageForeground(l, activeTheme), width, activeTheme, "userMessageBg"));
|
|
679
|
+
if (themed.length) { themed[0] = "\x1b]133;A\x07" + themed[0]; themed[themed.length - 1] = "\x1b]133;B\x07\x1b]133;C\x07" + themed[themed.length - 1]; }
|
|
680
|
+
self.mouseLayout = { width, children: [{ component: {}, height: themed.length }] };
|
|
681
|
+
return themed;
|
|
682
|
+
}
|
|
683
|
+
if (self.skillBlock) {
|
|
684
|
+
// skillRendering=native:回退 pi 原生渲染,但去掉 Box(1,1) 上下各一行整宽紫底 padding 空行
|
|
685
|
+
// (一南 2026-10-05)。只 trim 首尾纯背景空行;内容行(左内边距 + customMessageBg 底色)原样保留。
|
|
686
|
+
if (config.skillRendering !== "claude") {
|
|
687
|
+
const raw: string[] = orig.call(self, width);
|
|
688
|
+
const blankRow = (l: string) => l.replace(/\x1b\[[0-9;]*m/g, "").trim() === "";
|
|
689
|
+
let top = 0, bottom = raw.length;
|
|
690
|
+
while (top < bottom && blankRow(raw[top])) top++;
|
|
691
|
+
while (bottom > top && blankRow(raw[bottom - 1])) bottom--;
|
|
692
|
+
const trimmed = raw.slice(top, bottom);
|
|
693
|
+
// 顶部 padding 行删掉后点击会错位 top 行:实例级补一层 y+top 再交还原生 Box.handleMouse
|
|
694
|
+
// (其内部做 contentY = y - paddingY 的映射,x 与内部 mouseLayout 分发照旧)。own property
|
|
695
|
+
// 覆盖原型方法,hasOwnProperty 防止 /reload 反复包茧。
|
|
696
|
+
if (trimmed.length !== raw.length && !Object.prototype.hasOwnProperty.call(self, "handleMouse")) {
|
|
697
|
+
self.handleMouse = (event: any) =>
|
|
698
|
+
Box.prototype.handleMouse.call(self, { ...event, y: (event?.y ?? 0) + top });
|
|
699
|
+
}
|
|
700
|
+
return trimmed;
|
|
701
|
+
}
|
|
702
|
+
const name = oneLine(self.skillBlock.name), lines = [` ${fg(theme(), "success", "●")} ${bold(theme(), "Skill")}(${name})`];
|
|
703
|
+
lines.push(...prefixed(fg(theme(), "muted", "Loaded skill") + (self.expanded ? "" : ` (${keyText("app.tools.expand")} to expand)`), width, ` ${fg(theme(), "dim", "⎿")} `, " "));
|
|
704
|
+
if (self.expanded) {
|
|
705
|
+
const md = new Markdown(self.skillBlock.content ?? "", 0, 0, self.markdownTheme ?? getMarkdownTheme());
|
|
706
|
+
for (const l of md.render(Math.max(1, width - 3))) lines.push(fit(" " + l, width));
|
|
707
|
+
}
|
|
708
|
+
self.mouseLayout = { width, children: [{ component: { handleMouse(e: any) { if (e.y !== 0 || e.type !== "click" || e.button !== "left") return undefined; self.setExpanded(!self.expanded); redraw(); return { handled: true }; } }, height: lines.length }] };
|
|
709
|
+
return lines.map((l) => fit(l, width));
|
|
710
|
+
}
|
|
711
|
+
return orig.call(self, width); // Custom entries and images keep native structure.
|
|
712
|
+
}
|
|
713
|
+
function fail(error: unknown): void {
|
|
714
|
+
if (disabled) return;
|
|
715
|
+
disabled = true; setRunning(false);
|
|
716
|
+
for (const cc of chatContainers) { cc.render = cc[ORIGINAL_RENDER] ?? Container.prototype.render; delete cc[ORIGINAL_RENDER]; delete cc.__claudeChatWrapped; }
|
|
717
|
+
for (const cc of chatContainers) for (const child of cc.children ?? []) {
|
|
718
|
+
if (isAssistant(child) && child.contentContainer?.__leanTidyFilteredContainer) { child.contentContainer.render = Container.prototype.render; delete child.contentContainer.__leanTidyFilteredContainer; }
|
|
719
|
+
}
|
|
720
|
+
report(`Claude display disabled: ${error instanceof Error ? error.message : String(error)}. Native display restored; /reload after updating Pi compatibility.`);
|
|
721
|
+
redraw();
|
|
722
|
+
}
|
|
723
|
+
function dispose(): void {
|
|
724
|
+
setRunning(false);
|
|
725
|
+
finishThinking();
|
|
726
|
+
}
|
|
727
|
+
const api = {
|
|
728
|
+
enabled: () => !disabled,
|
|
729
|
+
fail, hookMode, renderChat, dispose,
|
|
730
|
+
toolExecRender: (self: any, orig: Function, width: number) => renderTool(self, orig, width),
|
|
731
|
+
toolExecHandleMouse: (self: any, orig: Function, event: any) => disabled ? orig.call(self, event) : Container.prototype.handleMouse.call(self, event),
|
|
732
|
+
assistantRender: renderAssistant,
|
|
733
|
+
assistantUpdateContent(self: any, orig: Function, message: any, streaming?: boolean) {
|
|
734
|
+
if (disabled) return orig.call(self, message, streaming);
|
|
735
|
+
// Retire the previous profile's live overrides when entering the new renderer.
|
|
736
|
+
if (!self.__claudeAssistant) { self.__claudeAssistant = true; if (self.__tidyLiveRun !== undefined) self.thinkingVisibilityOverrides?.delete(self.__tidyLiveRun); self.__tidyLiveRun = undefined; self.__tidyFold = undefined; }
|
|
737
|
+
return orig.call(self, message, streaming);
|
|
738
|
+
},
|
|
739
|
+
genericRender: renderGeneric,
|
|
740
|
+
modeRenderInitial(self: any, orig: Function, ...args: any[]) { hookMode(self); return orig.apply(self, args); },
|
|
741
|
+
modeAddMessage(self: any, orig: Function, ...args: any[]) { hookMode(self); return orig.apply(self, args); },
|
|
742
|
+
modeSetToolsExpanded(self: any, orig: Function, expanded: boolean) {
|
|
743
|
+
if (disabled) return orig.call(self, expanded);
|
|
744
|
+
open.clear(); toolOverrides.clear(); stageAnim.clear(); stageAutoOpen.clear(); const result = orig.call(self, expanded);
|
|
745
|
+
self.hideThinkingBlock = !expanded; self.updateThinkingBlockVisibility(); redraw(); return result;
|
|
746
|
+
},
|
|
747
|
+
modeUpdateThinkingVisibility(self: any, orig: Function) {
|
|
748
|
+
if (disabled) return orig.call(self);
|
|
749
|
+
open.clear();
|
|
750
|
+
stageAnim.clear();
|
|
751
|
+
stageAutoOpen.clear();
|
|
752
|
+
// Ctrl+T / 设置切换:显示 → 给现存组件打标,让已封口的阶段也把 thinking 展开;
|
|
753
|
+
// 隐藏 → 清标回到自动折叠。只影响切换那一刻已有的组件,新阶段照常走生命周期。
|
|
754
|
+
const reveal = !self.hideThinkingBlock;
|
|
755
|
+
for (const cc of chatContainers) for (const child of cc.children ?? []) {
|
|
756
|
+
if (!isAssistant(child) && !isTool(child)) continue;
|
|
757
|
+
if (reveal) child.__claudeReveal = true;
|
|
758
|
+
else delete child.__claudeReveal;
|
|
759
|
+
}
|
|
760
|
+
return orig.call(self);
|
|
761
|
+
},
|
|
762
|
+
modeHandleEvent(self: any, orig: Function, event: any, ...rest: any[]) {
|
|
763
|
+
hookMode(self); return orig.call(self, event, ...rest);
|
|
764
|
+
},
|
|
765
|
+
onEvent(event: any) { if (!disabled) observe(event); },
|
|
766
|
+
onAgentStart() { if (disabled) return; clock = { elapsed: new Map(), lengths: [] }; currentMessage = undefined; setRunning(true); },
|
|
767
|
+
onSettled() { finishThinking(); setRunning(false); currentMessage = undefined; redraw(); },
|
|
768
|
+
onSession() { clock = { elapsed: new Map(), lengths: [] }; currentMessage = undefined; clockByMessage = new WeakMap(); open.clear(); stageAutoOpen.clear(); toolOverrides.clear(); tailOff.clear(); stagePrevSealed.clear(); stageRows.clear(); stageAnim.clear(); for (const cc of chatContainers) for (const child of cc.children ?? []) if (child?.__claudeReveal) delete child.__claudeReveal; setRunning(false); },
|
|
769
|
+
exportTimings() { return [...clockByStamp].map(([key, elapsed]) => ({ key, elapsed: [...elapsed] })); },
|
|
770
|
+
restoreTimings(entries: any[]) { for (const entry of entries) if (typeof entry?.key === "string" && Array.isArray(entry.elapsed)) clockByStamp.set(entry.key, new Map(entry.elapsed.filter((p: any) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isFinite(p[1]) && p[1] >= 0))); },
|
|
771
|
+
latestTiming() { const key = stamp(currentMessage); return key && clockByStamp.has(key) ? { key, elapsed: [...clockByStamp.get(key)!] } : undefined; },
|
|
772
|
+
state() { return { inFlight, disabled, timer: !!timer }; },
|
|
773
|
+
};
|
|
774
|
+
return api;
|
|
775
|
+
}
|