pi-cc-extensions 0.9.7 → 0.9.9

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/README.en.md CHANGED
@@ -60,7 +60,7 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
60
60
  "expandedInputMaxLines": 5, // expanded tool Input lines; overflow shows a footer hint
61
61
  "expandedOutputMaxLines": 10, // expanded tool Output lines; overflow shows a footer hint
62
62
  "expandedPreviewMaxLines": 40, // max lines for expanded TaskList bodies (expanded diffs always show all)
63
- "inputClip": 100, // tool summary path/command clip length
63
+ "inputClip": 0, // tool summary path/command clip length; 0 = fit width
64
64
  "showStartupHeader": true, // startup header (logo + tips) toggle
65
65
  "scrollStepLines": 3, // fullscreen wheel scroll step
66
66
 
package/README.md CHANGED
@@ -60,7 +60,7 @@ pi install git:github.com/minuque/pi-cc-extensions
60
60
  "expandedInputMaxLines": 5, // 展开工具卡 Input 可见行数,超出在末行显示展开提示
61
61
  "expandedOutputMaxLines": 10, // 展开工具卡 Output 可见行数,超出在末行显示展开提示
62
62
  "expandedPreviewMaxLines": 40, // 展开 TaskList 正文最大行数(展开 diff 始终全量)
63
- "inputClip": 100, // 工具摘要 path/command 折叠字符数
63
+ "inputClip": 0, // 工具摘要 path/command 折叠字符数;0 = 按可用宽度
64
64
  "showStartupHeader": true, // 启动头(logo + tips)开关
65
65
  "scrollStepLines": 3, // fullscreen 滚轮步进
66
66
 
@@ -92,8 +92,8 @@ export const EXPANDED_PREVIEW_MAX_LINES_VALUES = ["40", "60", "80", "120", "200"
92
92
  export const EXPANDED_INPUT_MAX_LINES_VALUES = ["5", "10", "20", "40", "80"];
93
93
  /** 展开工具卡 Output 可见行数预设。 */
94
94
  export const EXPANDED_OUTPUT_MAX_LINES_VALUES = ["10", "20", "40", "80", "120"];
95
- /** 工具摘要里 path/command 等输入的折叠字符数。 */
96
- export const INPUT_CLIP_VALUES = ["40", "60", "80", "100", "120", "160"];
95
+ /** 工具摘要里 path/command 等输入的折叠字符数;0 = 只按可用宽度截断。 */
96
+ export const INPUT_CLIP_VALUES = ["0", "40", "60", "80", "100", "120", "160"];
97
97
  export const THINKING_PREVIEW_LINES_VALUES = ["0", "1", "3", "5", "10"];
98
98
  export const THINKING_ANIMATION_INTERVAL_VALUES = ["40", "60", "90", "120", "180"];
99
99
  /** fullscreen 滚轮步进行数预设。 */
@@ -124,7 +124,7 @@ export const DEFAULT_CONFIG: Config = {
124
124
  expandedPreviewMaxLines: DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
125
125
  expandedInputMaxLines: 5,
126
126
  expandedOutputMaxLines: 10,
127
- inputClip: 100,
127
+ inputClip: 0,
128
128
  useSummaryTitlesAsThinkingTitle: true,
129
129
  previewLines: 3,
130
130
  animationIntervalMs: 90,
@@ -154,6 +154,12 @@ export function pickPositiveInt(value: unknown, fallback: number, min = 1, max =
154
154
  return Math.min(max, Math.max(min, Math.floor(n)));
155
155
  }
156
156
 
157
+ /** inputClip:0 表示按宽度自适应,其余夹到 8..500。 */
158
+ export function pickInputClip(value: unknown): number {
159
+ const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
160
+ return n === 0 ? 0 : pickPositiveInt(value, DEFAULT_CONFIG.inputClip, 8, 500);
161
+ }
162
+
157
163
  export function pickPositiveNumber(value: unknown, fallback: number, min = 1): number {
158
164
  const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
159
165
  return Number.isFinite(n) ? Math.max(min, n) : fallback;
@@ -217,7 +223,7 @@ export function normalizeConfig(input: unknown): Config {
217
223
  1,
218
224
  5_000,
219
225
  ),
220
- inputClip: pickPositiveInt(source.inputClip, DEFAULT_CONFIG.inputClip, 8, 500),
226
+ inputClip: pickInputClip(source.inputClip),
221
227
  useSummaryTitlesAsThinkingTitle: source.useSummaryTitlesAsThinkingTitle !== false,
222
228
  previewLines: pickPositiveInt(
223
229
  source.previewLines,
@@ -38,6 +38,7 @@ import {
38
38
  EXPANDED_PREVIEW_MAX_LINES_VALUES,
39
39
  formatExcludeRenderers,
40
40
  getCompactThinkingConfig,
41
+ pickInputClip,
41
42
  pickPositiveInt,
42
43
  pickPositiveNumber,
43
44
  SCROLL_STEP_LINES_VALUES,
@@ -403,7 +404,7 @@ export async function showCcstylePanel(
403
404
  id: "inputClip",
404
405
  label: "Input clip",
405
406
  description:
406
- "Max characters for path/command/name in single and grouped tool summaries. Enter to type a custom value.",
407
+ "Max characters for path/command/name in single and grouped tool summaries; 0 fits available width. Enter to type a custom value.",
407
408
  currentValue: String(config.inputClip),
408
409
  values: [...INPUT_CLIP_VALUES],
409
410
  submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
@@ -667,7 +668,7 @@ export async function showCcstylePanel(
667
668
  switch (id) {
668
669
  case "inputClip":
669
670
  updateConfig({
670
- inputClip: pickPositiveInt(value, DEFAULT_CONFIG.inputClip, 8, 500),
671
+ inputClip: pickInputClip(value),
671
672
  });
672
673
  inputClipSetting.currentValue = String(config.inputClip);
673
674
  break;
@@ -1,9 +1,14 @@
1
- import { VERSION, type AppKeybinding } from "@earendil-works/pi-coding-agent";
1
+ import { InteractiveMode, VERSION, type AppKeybinding } from "@earendil-works/pi-coding-agent";
2
2
  import { getKeybindings } from "@earendil-works/pi-tui";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { config } from "../../config/config.ts";
5
5
  import { ansi16ToRgb, ansi256ToRgb } from "../../utils/ansi-color.ts";
6
6
  import { stripAnsi } from "../../utils/ansi-text.ts";
7
+ import {
8
+ EARLY_STARTUP_HEADER_PATCH,
9
+ EARLY_STARTUP_HEADER_SWAP_KEY,
10
+ patchRegistry,
11
+ } from "../../utils/patch-keys.ts";
7
12
  type Rgb = [number, number, number];
8
13
  type StyledPart = {
9
14
  raw: string;
@@ -279,17 +284,88 @@ export function renderHeaderLines(
279
284
  );
280
285
  }
281
286
 
287
+ /** 官方 setHeader 的组件工厂:theme 由宿主注入。 */
288
+ function headerFactory(): (
289
+ tui: unknown,
290
+ theme: any,
291
+ ) => {
292
+ render(width: number): string[];
293
+ invalidate(): void;
294
+ } {
295
+ return (_tui: unknown, theme: any) => ({
296
+ render(width: number): string[] {
297
+ return renderHeaderLines(width, theme);
298
+ },
299
+ invalidate() {},
300
+ });
301
+ }
302
+
282
303
  /** 按配置安装启动头。禁用时不碰槽位,避免清掉其他扩展的 header。 */
283
304
  export function applyStartupHeader(ctx: any): void {
284
305
  if (!ctx?.hasUI || typeof ctx.ui?.setHeader !== "function" || !config.showStartupHeader) {
285
306
  return;
286
307
  }
287
- ctx.ui.setHeader((_tui: unknown, theme: any) => ({
288
- render(width: number): string[] {
289
- return renderHeaderLines(width, theme);
308
+ ctx.ui.setHeader(headerFactory());
309
+ }
310
+
311
+ type EarlyHeaderPatch = {
312
+ active: boolean;
313
+ original: (...args: any[]) => unknown;
314
+ installed: (...args: any[]) => unknown;
315
+ };
316
+
317
+ type HeaderContainerSwap = {
318
+ original: (child: any) => unknown;
319
+ installed: (child: any) => unknown;
320
+ };
321
+
322
+ /**
323
+ * Pi 在 session_start 之前就把原生启动头画上去了:init() 先建 header 再 requestRender,
324
+ * 之后才 await 工具检查、加载扩展,所以扩展最早只能到 session_start 换头,启动时会闪一下原生头。
325
+ * 这里补 init,在原生 header 刚进容器的瞬间换成 ccstyle 的,首次绘制就已经是我们的。
326
+ * 判定用 child === mode.builtInHeader,不依赖类名;配置关掉时不碰槽位,保留原生头。
327
+ */
328
+ export function installEarlyStartupHeader(): void {
329
+ const prototype = (InteractiveMode as any)?.prototype;
330
+ if (!prototype || typeof prototype.init !== "function") return;
331
+ const previous = patchRegistry.get<EarlyHeaderPatch>(EARLY_STARTUP_HEADER_PATCH);
332
+ if (previous) previous.active = false;
333
+ const original =
334
+ previous && prototype.init === previous.installed ? previous.original : prototype.init;
335
+ const patch: EarlyHeaderPatch = {
336
+ active: true,
337
+ original,
338
+ installed: async function (this: any, ...args: any[]) {
339
+ installHeaderContainerSwap(this, patch);
340
+ return original.apply(this, args);
290
341
  },
291
- invalidate() {},
292
- }));
342
+ };
343
+ prototype.init = patch.installed;
344
+ patchRegistry.install(EARLY_STARTUP_HEADER_PATCH, patch);
345
+ }
346
+
347
+ /** 只包一次 headerContainer.addChild;/reload 后按所有权接管旧包装。 */
348
+ function installHeaderContainerSwap(mode: any, patch: EarlyHeaderPatch): void {
349
+ const container = mode?.headerContainer;
350
+ if (!container || typeof container.addChild !== "function") return;
351
+ const previous = container[EARLY_STARTUP_HEADER_SWAP_KEY] as HeaderContainerSwap | undefined;
352
+ const original =
353
+ previous && container.addChild === previous.installed
354
+ ? previous.original
355
+ : container.addChild.bind(container);
356
+ const installed = function (child: any) {
357
+ const result = original(child);
358
+ if (!patch.active || !config.showStartupHeader || child !== mode.builtInHeader) return result;
359
+ if (typeof mode.setExtensionHeader !== "function") return result;
360
+ try {
361
+ mode.setExtensionHeader(headerFactory());
362
+ } catch {
363
+ // 换头失败就退回原生 header,不影响启动
364
+ }
365
+ return result;
366
+ };
367
+ container.addChild = installed;
368
+ container[EARLY_STARTUP_HEADER_SWAP_KEY] = { original, installed };
293
369
  }
294
370
 
295
371
  /** 用户从 /ccstyle 关掉本扩展启动头时,恢复官方 header。 */
@@ -115,18 +115,30 @@ export default function (pi: ExtensionAPI): void {
115
115
  return parts.length ? `Working... (${parts.join(" · ")})` : "";
116
116
  }
117
117
 
118
+ function workingUiAvailable(): boolean {
119
+ try {
120
+ return activeCtx?.hasUI === true;
121
+ } catch {
122
+ // 会话替换/reload 后捕获的 ctx 失效,getter 抛错;停止驱动 footer。
123
+ turnActive = false;
124
+ activeCtx = null;
125
+ stopRefreshLoop();
126
+ return false;
127
+ }
128
+ }
129
+
118
130
  function restoreDefaultWorkingMessage(): void {
119
131
  lastMessage = null;
120
- if (!activeCtx?.hasUI) return;
132
+ if (!workingUiAvailable()) return;
121
133
  try {
122
- activeCtx.ui?.setWorkingMessage();
134
+ activeCtx?.ui?.setWorkingMessage();
123
135
  } catch {
124
136
  // Noop when the TUI is unavailable.
125
137
  }
126
138
  }
127
139
 
128
140
  function syncWorkingMessage(force = false): void {
129
- if (!activeCtx?.hasUI) return;
141
+ if (!workingUiAvailable()) return;
130
142
  const next = buildWorkingMessage();
131
143
  if (!next) {
132
144
  if (force) restoreDefaultWorkingMessage();
@@ -135,7 +147,7 @@ export default function (pi: ExtensionAPI): void {
135
147
  if (!force && next === lastMessage) return;
136
148
  lastMessage = next;
137
149
  try {
138
- activeCtx.ui?.setWorkingMessage(next);
150
+ activeCtx?.ui?.setWorkingMessage(next);
139
151
  } catch {
140
152
  // Noop when the TUI is unavailable.
141
153
  }
@@ -147,9 +159,12 @@ export default function (pi: ExtensionAPI): void {
147
159
  refreshTimer = null;
148
160
  try {
149
161
  syncWorkingMessage();
150
- } finally {
151
- scheduleRefreshTick();
162
+ } catch {
163
+ // 定时器内的异常会成为 uncaughtException 终止 Pi;装饰性刷新直接停止。
164
+ turnActive = false;
165
+ return;
152
166
  }
167
+ scheduleRefreshTick();
153
168
  }, REFRESH_INTERVAL_MS);
154
169
  refreshTimer.unref?.();
155
170
  }
@@ -5,7 +5,7 @@ import { config } from "./config/config.ts";
5
5
  import piAliases from "./feature/shell/aliases.ts";
6
6
  import { installFlushDockedBash } from "./feature/shell/flush-docked-bash.ts";
7
7
  import customFooter from "./feature/shell/footer.ts";
8
- import piStartupHeader from "./feature/shell/startup-header.ts";
8
+ import piStartupHeader, { installEarlyStartupHeader } from "./feature/shell/startup-header.ts";
9
9
  import workingMessage from "./feature/shell/working-message.ts";
10
10
 
11
11
  // feature
@@ -23,6 +23,7 @@ export default function (pi: ExtensionAPI): void {
23
23
  // shell chrome
24
24
  if (config.enableAliases) piAliases(pi);
25
25
  installFlushDockedBash();
26
+ installEarlyStartupHeader();
26
27
  piStartupHeader(pi);
27
28
  if (config.enableWorkingMessage) workingMessage(pi);
28
29
  customFooter(pi);
@@ -8,10 +8,13 @@
8
8
  * Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
9
9
  * 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
10
10
  *
11
- * live 围观态(回合进行中 / 收尾静默期):正文与摘要行跟折叠态同形,只在摘要行下面多一张
12
- * 单一槽位卡(`↳` + 最新的一个块:仍在增长的思考预览,或本回合最近的工具,运行中优先)。
11
+ * live 围观态(回合进行中 / 收尾静默期):正文跟折叠态同形;摘要行与单一槽位卡
12
+ * (`↳` + 最新的一个块:仍在增长的思考预览,或本回合最近的工具,运行中优先)挂在
13
+ * transcript 容器的回合末尾(尾行组件,anchor 与工具卡之后、下一消息之前)。
13
14
  * 思考原地增长不轮换,只有新的思考块或工具调用进来才换槽位内容。
14
- * 收尾时摘要行原地从 Running... 翻成 Ran for、槽位卡消失,正文与摘要行都不换位置。
15
+ * 摘要行挂末尾是刻意的:运行中它恒在可写视口底缘,收尾时原地从 Running... 翻成
16
+ * Ran for 真实落进 scrollback;放在 anchor 内部则会被工具/diff 顶出视口后定格。
17
+ * (anchor 未挂载进容器的独立渲染场景退回组件内旧行为。)
15
18
  *
16
19
  * 工具计数:read 按非空路径去重、其余按调用计数(首次出现顺序);edit/write 不进摘要。
17
20
  * 时长 = 回合流逝挂钟;进行中 Running...,结束 Ran for。
@@ -60,6 +63,7 @@ import {
60
63
  COMPACT_THINKING_PATCH_KEY,
61
64
  patchRegistry,
62
65
  PROTOTYPE_ORIGINAL_KEY,
66
+ TOOL_GROUPING_PARENT_KEY,
63
67
  } from "../utils/patch-keys.ts";
64
68
 
65
69
  /** Pi 的 native 组件与扩展可能各持一份 pi-tui,Spacer 不能只靠 instanceof 判。 */
@@ -593,6 +597,22 @@ export function getCompactRunStatusText(): string | undefined {
593
597
  return compactRunStatusGetter?.();
594
598
  }
595
599
 
600
+ /**
601
+ * 回合尾行记录:摘要行(live 时连槽位卡)挂成 transcript 容器里回合末尾的
602
+ * 兄弟组件,而不是 anchor 内部。anchor 内部的可变内容一旦被工具/diff 顶出视口
603
+ * 就再也写不进 scrollback(regular 主屏只能差分可视区),运行态 Running 会永久
604
+ * 定格在挤出前最后一帧;尾行恒在可写底缘,收尾的 Ran for 才真实落进历史。
605
+ * 模块级持有(与 trackedAssistantComponents 同理):跨 /reload 去重与清理。
606
+ */
607
+ type MountedRoundTail = {
608
+ host: any;
609
+ container: any;
610
+ parts: any[];
611
+ round: any;
612
+ };
613
+ const tailByAnchor = new WeakMap<object, MountedRoundTail>();
614
+ const mountedTails = new Set<MountedRoundTail>();
615
+
596
616
  export function setHoveredCompactAssistant(component: any): boolean {
597
617
  if (hoveredAssistantComponent === component) return false;
598
618
  hoveredAssistantComponent = component;
@@ -998,16 +1018,6 @@ function compactLiveSlot(card: any, pad = 0): any {
998
1018
  };
999
1019
  }
1000
1020
 
1001
- function compactAssistantLine(
1002
- component: any,
1003
- summary: string | (() => string),
1004
- query?: CompactThinkingQuery,
1005
- ): void {
1006
- // 空静态串跳过;getter 留给 render 判断(Running 可能稍后才有时长)。
1007
- if (typeof summary === "string" && !summary) return;
1008
- component.contentContainer.addChild(compactAssistantLineComponent(component, summary, query));
1009
- }
1010
-
1011
1021
  function appendStopStatus(component: any, status: string | undefined): void {
1012
1022
  if (!status || !component?.contentContainer?.addChild) return;
1013
1023
  component.contentContainer.addChild(
@@ -1117,6 +1127,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1117
1127
  /** 回合挂钟起点,保证 Running 时长连续递增。 */
1118
1128
  startedAt: number;
1119
1129
  endedAt?: number;
1130
+ /** 回合尾行(摘要 + live 槽位),挂在 transcript 容器的回合末尾。 */
1131
+ tail?: MountedRoundTail;
1120
1132
  };
1121
1133
  let activeRound: CompactRound | undefined;
1122
1134
  let roundByComponent = new WeakMap<object, CompactRound>();
@@ -1155,6 +1167,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1155
1167
  stopRoundTick();
1156
1168
  return;
1157
1169
  }
1170
+ settleStrandedRounds();
1158
1171
  try {
1159
1172
  // 非 force:保留 fullscreen 布局缓存和差分绘制。
1160
1173
  uiRef?.requestRender?.();
@@ -1287,6 +1300,236 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1287
1300
  return ids;
1288
1301
  };
1289
1302
 
1303
+ /**
1304
+ * anchor 所在的 transcript 容器(pi chatContainer)。
1305
+ * 优先走分组补丁在 addChild 时挂的父指针;没有则沿 tui 组件树找直属父容器。
1306
+ */
1307
+ const tailContainerOf = (anchor: any): any => {
1308
+ const keyed = anchor?.[TOOL_GROUPING_PARENT_KEY];
1309
+ if (keyed && Array.isArray(keyed.children) && keyed.children.includes(anchor)) {
1310
+ return keyed;
1311
+ }
1312
+ let found: any;
1313
+ walkComponentTree(getToolMouseTui(), (value: any) => {
1314
+ if (found) return false;
1315
+ if (Array.isArray(value?.children) && value.children.includes(anchor)) {
1316
+ found = value;
1317
+ return false;
1318
+ }
1319
+ return undefined;
1320
+ });
1321
+ return found;
1322
+ };
1323
+
1324
+ /** 组件在树里的直属父容器。TUI 切换/reparent 后缓存的 container 会过期。 */
1325
+ const containerOfHost = (host: any): any => {
1326
+ let found: any;
1327
+ walkComponentTree(getToolMouseTui(), (value: any) => {
1328
+ if (found) return false;
1329
+ if (Array.isArray(value?.children) && value.children.includes(host)) {
1330
+ found = value;
1331
+ return false;
1332
+ }
1333
+ return undefined;
1334
+ });
1335
+ return found;
1336
+ };
1337
+
1338
+ /** 尾行实际所在容器;缓存的 container 过期时按树找回来。 */
1339
+ const tailHostContainer = (tail: MountedRoundTail): any =>
1340
+ containerOfHost(tail.host) ??
1341
+ (tail.container?.children?.includes?.(tail.host) === true ? tail.container : undefined);
1342
+
1343
+ /** 摘尾行:按实际所在容器摘,避免缓存过期时摘了个空、行留在树上。 */
1344
+ const removeTailHost = (tail: MountedRoundTail): void => {
1345
+ tailHostContainer(tail)?.removeChild?.(tail.host);
1346
+ };
1347
+
1348
+ const ensureTailHost = (round: CompactRound): MountedRoundTail => {
1349
+ if (round.tail) return round.tail;
1350
+ // 同一 anchor 上残留的旧回合尾行(reset/重组后建新回合)先摘除,避免双摘要。
1351
+ const stale = tailByAnchor.get(round.anchor);
1352
+ if (stale) {
1353
+ removeTailHost(stale);
1354
+ mountedTails.delete(stale);
1355
+ tailByAnchor.delete(round.anchor);
1356
+ }
1357
+ const tail: MountedRoundTail = { host: undefined, container: undefined, parts: [], round };
1358
+ const host: any = {
1359
+ outputPad: Number(round.anchor?.outputPad) || 0,
1360
+ render: (width: number) => tail.parts.flatMap((part: any) => part?.render?.(width) ?? []),
1361
+ invalidate: () => {
1362
+ for (const part of tail.parts) part?.invalidate?.();
1363
+ },
1364
+ };
1365
+ Object.defineProperty(host, "expanded", {
1366
+ configurable: true,
1367
+ get: () => tail.round?.anchor?.expanded === true,
1368
+ set: (value: boolean) => {
1369
+ const anchor = tail.round?.anchor;
1370
+ if (anchor) anchor.expanded = value;
1371
+ },
1372
+ });
1373
+ // 点击/悬停归属与 anchor 同一展开入口;toggle 每次 renderRound 重建,按值转发。
1374
+ // 经 tail.round 动态解引用:重建过户时 anchor 可能换成别的组件。
1375
+ ensureAssistantSetExpanded(host);
1376
+ host[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) =>
1377
+ tail.round?.anchor?.[ASSISTANT_TOGGLE_ROUND_KEY]?.(expanded);
1378
+ tail.host = host;
1379
+ tailByAnchor.set(round.anchor, tail);
1380
+ mountedTails.add(tail);
1381
+ round.tail = tail;
1382
+ return tail;
1383
+ };
1384
+
1385
+ /**
1386
+ * 尾行归位。运行中(含收尾静默期)尾行恒为容器最末一行:计数行钉在
1387
+ * transcript 底缘,运行中到达的提示行/custom 条目只能落在它上方,计数
1388
+ * 不会被顶离底部截成两行。回合收尾后摘要行回落到回合最后一个成员之后,
1389
+ * 外来行统一换到摘要行之下,通知行永远落在回合边界外。
1390
+ */
1391
+ const syncTailPosition = (round: CompactRound): void => {
1392
+ const tail = round.tail;
1393
+ if (!tail?.host) return;
1394
+ const container =
1395
+ tail.container?.children?.includes?.(round.anchor) === true
1396
+ ? tail.container
1397
+ : tailContainerOf(round.anchor);
1398
+ if (!container || !Array.isArray(container.children)) return;
1399
+ tail.container = container;
1400
+ const children = container.children as any[];
1401
+ const current = children.indexOf(tail.host);
1402
+ if (current >= 0) children.splice(current, 1);
1403
+ const ids = roundToolCallIds(round);
1404
+ const matchesTool = (value: any): boolean =>
1405
+ typeof value?.toolCallId === "string" && ids.has(value.toolCallId);
1406
+ const isMember = (value: any): boolean =>
1407
+ round.messages.has(value) ||
1408
+ matchesTool(value) ||
1409
+ (Array.isArray(value?.children) && value.children.some(matchesTool));
1410
+ let first = -1;
1411
+ let last = -1;
1412
+ for (let i = 0; i < children.length; i++) {
1413
+ if (!isMember(children[i])) continue;
1414
+ if (first < 0) first = i;
1415
+ last = i;
1416
+ }
1417
+ const lastMember = last >= 0 ? children[last] : undefined;
1418
+ const intruders: any[] = [];
1419
+ for (let i = last - 1; i > first; i--) {
1420
+ if (!isMember(children[i])) intruders.unshift(children.splice(i, 1)[0]);
1421
+ }
1422
+ if (lastMember) last = children.indexOf(lastMember);
1423
+ const insertAt = last < 0 ? children.length : last + 1;
1424
+ if (roundLive(round)) {
1425
+ // 活回合:外来行压到最后成员之后,尾行占容器最末。
1426
+ children.splice(insertAt, 0, ...intruders);
1427
+ children.push(tail.host);
1428
+ } else {
1429
+ children.splice(insertAt, 0, tail.host, ...intruders);
1430
+ }
1431
+ tail.host[TOOL_GROUPING_PARENT_KEY] = container;
1432
+ };
1433
+
1434
+ const unmountRoundTail = (round: CompactRound): void => {
1435
+ const tail = round.tail;
1436
+ if (!tail) return;
1437
+ round.tail = undefined;
1438
+ removeTailHost(tail);
1439
+ mountedTails.delete(tail);
1440
+ if (tailByAnchor.get(round.anchor) === tail) tailByAnchor.delete(round.anchor);
1441
+ };
1442
+
1443
+ /**
1444
+ * pi 把新工具卡 append 在容器末尾(尾行之后):updateDisplay 时把所属回合的
1445
+ * 尾行压回末尾。只有 live/收尾静默期的回合还会长工具。
1446
+ */
1447
+ const syncTailForTool = (toolCallId: string): void => {
1448
+ if (!toolCallId) return;
1449
+ for (const round of [activeRound, ...pendingFoldRounds]) {
1450
+ if (round?.tail && roundToolCallIds(round).has(toolCallId)) {
1451
+ syncTailPosition(round);
1452
+ return;
1453
+ }
1454
+ }
1455
+ };
1456
+
1457
+ /**
1458
+ * refresh 重建时继承旧回合:resetRounds 只清索引不摘 compact 尾行,
1459
+ * 残留尾行带着旧 round。同一组件重放同一消息(lastMessage 回放)
1460
+ * 才算重建——保留挂钟起点、已脱离的工具历史与尾行组件本身;
1461
+ * 旧回合已收尾的直接落成折叠态(不回摆 Running、不重演静默期),
1462
+ * 仍活动的只接回挂钟,Running 时长继续走。
1463
+ */
1464
+ const adoptPriorRound = (round: CompactRound, message: any): void => {
1465
+ let prior: CompactRound | undefined;
1466
+ for (const tail of mountedTails) {
1467
+ const stale = tail.round as CompactRound | undefined;
1468
+ if (
1469
+ stale &&
1470
+ // 先认组件:重放时消息对象未必是同一个(会话重读/重建),
1471
+ // 只认消息身份会漏掉本该接回的回合,摘要行就此掉队。
1472
+ (stale.anchor === round.anchor ||
1473
+ stale.messages.has(round.anchor) ||
1474
+ stale.detachedMessages.includes(message))
1475
+ ) {
1476
+ prior = stale;
1477
+ break;
1478
+ }
1479
+ }
1480
+ if (!prior) return;
1481
+ round.startedAt = prior.startedAt;
1482
+ if (prior.detachedMessages.length) round.detachedMessages.push(...prior.detachedMessages);
1483
+ if (!prior.active) {
1484
+ round.active = false;
1485
+ round.endedAt = prior.endedAt ?? round.startedAt;
1486
+ }
1487
+ const tail = prior.tail;
1488
+ if (tail) {
1489
+ prior.tail = undefined;
1490
+ if (tailByAnchor.get(prior.anchor) === tail) tailByAnchor.delete(prior.anchor);
1491
+ tail.round = round;
1492
+ tailByAnchor.set(round.anchor, tail);
1493
+ round.tail = tail;
1494
+ }
1495
+ };
1496
+
1497
+ /** 新建活回合:literal + 激活 + refresh 重建时的旧回合收编。 */
1498
+ const createRound = (anchor: any, message: any): CompactRound => {
1499
+ const round: CompactRound = {
1500
+ anchor,
1501
+ messages: new Map(),
1502
+ detachedMessages: [],
1503
+ active: true,
1504
+ suppressedToolIds: new Set(),
1505
+ liveSlotToolIds: new Set(),
1506
+ startedAt: Date.now(),
1507
+ };
1508
+ activateRound(round);
1509
+ adoptPriorRound(round, message);
1510
+ return round;
1511
+ };
1512
+
1513
+ /**
1514
+ * 尾行挂载入口:makeParts 按渲染态产出内容(折叠态 [摘要行],live 态
1515
+ * [摘要行, 槽位卡]),identity 决定行内 hover/点击归属组件(尾行模式下是
1516
+ * host,hit-test 直达它)。anchor 未挂进任何容器时(独立渲染)退回
1517
+ * contentContainer——与旧行为一致。
1518
+ */
1519
+ const mountRoundTail = (round: CompactRound, makeParts: (identity: any) => any[]): void => {
1520
+ if (!tailContainerOf(round.anchor)) {
1521
+ // anchor 不在任何容器里(独立渲染/未挂载):退回 anchor 内部,与旧行为一致。
1522
+ unmountRoundTail(round);
1523
+ for (const part of makeParts(round.anchor)) {
1524
+ round.anchor?.contentContainer?.addChild?.(part);
1525
+ }
1526
+ return;
1527
+ }
1528
+ const tail = ensureTailHost(round);
1529
+ tail.parts = makeParts(tail.host);
1530
+ syncTailPosition(round);
1531
+ };
1532
+
1290
1533
  /**
1291
1534
  * 末块仍是 thinking = 思考还在长;后面跟了正文或工具调用就算结束。
1292
1535
  * 围观态只预览这一种,已完成的思考直接回收进摘要行,不在屏幕上留 Thought 行。
@@ -1371,21 +1614,26 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1371
1614
 
1372
1615
  const anchor = round.anchor;
1373
1616
  const pad = Number(anchor.outputPad) || 0;
1374
- // 摘要行位置与折叠态一致(正文之后),收尾只有时态变化和槽位卡消失。
1375
- compactAssistantLine(anchor, getSummary, deps.query);
1376
- if (cardItems.length > 0) {
1377
- anchor.contentContainer.addChild(
1378
- compactLiveSlot(
1379
- compactRoundCard(
1380
- cardItems,
1381
- (tool, innerWidth) => patch.toolOriginalRender.call(tool, innerWidth),
1382
- false,
1383
- true,
1617
+ // 摘要行与槽位卡挂回合尾行(transcript 容器内回合末尾,而非 anchor 内部):
1618
+ // 运行中恒在可写视口底缘,收尾的 Ran for 原地落进 scrollback,不再留下
1619
+ // 被挤出视口后定格的 Running 帧。
1620
+ mountRoundTail(round, (identity) => {
1621
+ const parts: any[] = [compactAssistantLineComponent(identity, getSummary, deps.query)];
1622
+ if (cardItems.length > 0) {
1623
+ parts.push(
1624
+ compactLiveSlot(
1625
+ compactRoundCard(
1626
+ cardItems,
1627
+ (tool, innerWidth) => patch.toolOriginalRender.call(tool, innerWidth),
1628
+ false,
1629
+ true,
1630
+ ),
1631
+ pad,
1384
1632
  ),
1385
- pad,
1386
- ),
1387
- );
1388
- }
1633
+ );
1634
+ }
1635
+ return parts;
1636
+ });
1389
1637
  appendStopStatus(anchor, stopStatus);
1390
1638
  };
1391
1639
 
@@ -1431,6 +1679,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1431
1679
  }
1432
1680
 
1433
1681
  if (round.anchor.expanded === true) {
1682
+ // 展开卡自带全部内容,尾行摘下(收起时在折叠分支重新挂载)。
1683
+ unmountRoundTail(round);
1434
1684
  const toolsById = new Map<string, any>();
1435
1685
  for (const tool of trackedToolComponents) {
1436
1686
  if (typeof tool?.toolCallId === "string") toolsById.set(tool.toolCallId, tool);
@@ -1556,7 +1806,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1556
1806
  if (component === round.anchor) {
1557
1807
  renderAssistantWithoutThinking(component, message);
1558
1808
  // 空摘要不挂行(getter 在 Running 启动瞬间也可能短暂为空)
1559
- if (summary || round.active) compactAssistantLine(component, getSummary, deps.query);
1809
+ if (summary || round.active) {
1810
+ mountRoundTail(round, (identity) => [
1811
+ compactAssistantLineComponent(identity, getSummary, deps.query),
1812
+ ]);
1813
+ } else {
1814
+ unmountRoundTail(round);
1815
+ }
1560
1816
  // 折叠时工具行被隐藏:abort/error/length 必须挂在摘要外层。
1561
1817
  appendStopStatus(component, stopStatus);
1562
1818
  } else {
@@ -1584,10 +1840,41 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1584
1840
  expandedRoundToolIds.clear();
1585
1841
  explicitRoundToolIds.clear();
1586
1842
  liveSlotToolIds.clear();
1843
+ // 尾行是 transcript 兄弟组件:compact 下保留为静态行(与原 anchor 内摘要
1844
+ // 一致;同 anchor 重建回合时 ensureTailHost 去重摘除),非 compact 摘掉,
1845
+ // 别把紧凑摘要带进 default 渲染。
1846
+ for (const tail of [...mountedTails]) {
1847
+ const container = tailHostContainer(tail);
1848
+ if (!container) {
1849
+ mountedTails.delete(tail);
1850
+ continue;
1851
+ }
1852
+ tail.container = container;
1853
+ if (config.mode === "compact") continue;
1854
+ container.removeChild(tail.host);
1855
+ mountedTails.delete(tail);
1856
+ if (tail.round?.tail === tail) tail.round.tail = undefined;
1857
+ if (tail.round?.anchor && tailByAnchor.get(tail.round.anchor) === tail) {
1858
+ tailByAnchor.delete(tail.round.anchor);
1859
+ }
1860
+ }
1587
1861
  deps.query?.setCompactSummaryActive?.(false);
1588
1862
  stopRoundTick();
1589
1863
  };
1590
1864
 
1865
+ /**
1866
+ * 兜底:resetRounds 会丢掉回合索引但保留已挂的尾行,重放没能接回的回合会一直
1867
+ * active——尾行永远显示 Running...(时长还在涨)却不再计入新工具。既不是活动
1868
+ * 回合也不在收尾静默期的活回合就地收尾,不让它永远挂在屏幕上。
1869
+ */
1870
+ const settleStrandedRounds = (): void => {
1871
+ for (const tail of [...mountedTails]) {
1872
+ const round = tail.round as CompactRound | undefined;
1873
+ if (!round?.active || round === activeRound || pendingFoldRounds.has(round)) continue;
1874
+ endRound(round, true);
1875
+ }
1876
+ };
1877
+
1591
1878
  patch.assistantInstalled = function (this: any, message: any, isStreaming?: boolean) {
1592
1879
  const self = this as any;
1593
1880
  // 同 compact-thinking:isStreaming 丢失 → mermaid 流式误渲染来回闪。
@@ -1616,29 +1903,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1616
1903
  if (hasText && (!round || round.anchor !== this)) {
1617
1904
  if (round) round.messages.delete(this);
1618
1905
  if (activeRound) finishRound(activeRound);
1619
- round = {
1620
- anchor: this,
1621
- messages: new Map(),
1622
- detachedMessages: [],
1623
- active: true,
1624
- suppressedToolIds: new Set(),
1625
- liveSlotToolIds: new Set(),
1626
- startedAt: Date.now(),
1627
- };
1906
+ round = createRound(this, message);
1628
1907
  roundByComponent.set(this, round);
1629
- activateRound(round);
1630
1908
  } else if (!round) {
1631
- round = activeRound ?? {
1632
- anchor: this,
1633
- messages: new Map(),
1634
- detachedMessages: [],
1635
- active: true,
1636
- suppressedToolIds: new Set(),
1637
- liveSlotToolIds: new Set(),
1638
- startedAt: Date.now(),
1639
- };
1909
+ round = activeRound ?? createRound(this, message);
1640
1910
  roundByComponent.set(this, round);
1641
- if (!activeRound) activateRound(round);
1642
1911
  }
1643
1912
  round.messages.set(this, message);
1644
1913
  renderRound(round);
@@ -1671,17 +1940,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1671
1940
  if (hasThinking) {
1672
1941
  let round = roundByComponent.get(this);
1673
1942
  if (!round) {
1674
- round = activeRound ?? {
1675
- anchor: this,
1676
- messages: new Map(),
1677
- detachedMessages: [],
1678
- active: true,
1679
- suppressedToolIds: new Set(),
1680
- liveSlotToolIds: new Set(),
1681
- startedAt: Date.now(),
1682
- };
1943
+ round = activeRound ?? createRound(this, message);
1683
1944
  roundByComponent.set(this, round);
1684
- if (!activeRound) activateRound(round);
1685
1945
  }
1686
1946
  round.messages.set(this, message);
1687
1947
  renderRound(round);
@@ -1733,6 +1993,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1733
1993
  const result = patch.toolOriginalUpdateDisplay.call(this);
1734
1994
  if (!patch.active) return result;
1735
1995
  trackedToolComponents.add(this);
1996
+ if (config.mode === "compact") syncTailForTool(id);
1736
1997
  return result;
1737
1998
  };
1738
1999
 
@@ -100,6 +100,8 @@ type ToolExpandedBackgroundPatch = {
100
100
  dispose: () => void;
101
101
  };
102
102
 
103
+ /** rich diff 的两个入口(本处与 compact 的 paintCompactEditWrite)都靠 renderRichToolResult
104
+ * 返回 undefined 让位,这里只判断模式与工具名,避免多一个可能与让位不一致的 gate。 */
103
105
  export function shouldRenderRichDiff(
104
106
  mode: CompactStyleMode,
105
107
  toolName: string,
@@ -12,7 +12,7 @@ import {
12
12
  installToolExpandedBackground,
13
13
  type DefaultModeHooks,
14
14
  } from "./default-mode.ts";
15
- import { isLazyProxyTui } from "../utils/fullscreen-detect.ts";
15
+ import { installMainScreenDoRenderPatch, isLazyProxyTui } from "../utils/fullscreen-detect.ts";
16
16
  import { showCcstylePanel } from "../config/panel.ts";
17
17
  import {
18
18
  config,
@@ -116,6 +116,8 @@ export default function (
116
116
  // 渲染层(工具样式/分组)是原型与组件级 patch,fullscreen 官方布局
117
117
  // 同样渲染这些组件,因此两种模式都安装。
118
118
  if (installation) return installation;
119
+ // regular 主屏的差分渲染补丁:视口上方变化不再触发清回滚的 fullRender。
120
+ installMainScreenDoRenderPatch();
119
121
  const defaultMode = installDefaultMode(writeExecutionMetadata);
120
122
  const toolGrouping = installToolGrouping(() => config.mode === "on");
121
123
  const compactMode = installCompactMode({
@@ -206,10 +208,18 @@ export default function (
206
208
  });
207
209
 
208
210
  pi.on("session_start", async (event, ctx) => {
209
- // 延迟到 session_start 注册 write override:加载阶段 getAllTools 不可用且其他扩展
210
- // 尚未注册工具,无法检测外部 write 所有者(如 pi-spark),直接注册会与对方撞名。
211
- // session_start 时所有扩展已加载完毕,installWriteOverride 内部会检测并让位。
212
- installWriteOverride(pi, writeExecutionMetadata);
211
+ // 延后一拍再确认 write 归属:其他扩展同样在各自的 session_start 里注册工具,
212
+ // 本 handler 执行时可能还看不到对方,此时注册会与对方撞名(注册表先到先得),
213
+ // 对方的 write 会被静默丢弃。等所有 session_start 跑完,检测结果才是权威的。
214
+ setTimeout(() => {
215
+ installWriteOverride(pi, writeExecutionMetadata, (owner) => {
216
+ // 冲突可见:对方接管 write 后本插件不再提供富 diff。
217
+ ctx.ui?.notify?.(
218
+ `ccstyle: write 工具已被 ${owner.path || owner.source} 占用,富 diff 已让位`,
219
+ "warning",
220
+ );
221
+ });
222
+ }, 0);
213
223
  const hooks = ensureTuiInstallation(ctx);
214
224
  // 鼠标交互独立于渲染层:fullscreen 渲染层让位(hooks undefined)但
215
225
  // 工具点击/回到底部适配仍需安装;保持在渲染层安装之后以维持原顺序。
@@ -6,6 +6,7 @@ import {
6
6
  type DisplayConfigInput,
7
7
  } from "./diff-renderer.ts";
8
8
  import { DEFAULT_TOOL_DISPLAY_CONFIG } from "../../../config/config.ts";
9
+ import { patchRegistry, WRITE_OWNERSHIP_SLOT } from "../../../utils/patch-keys.ts";
9
10
  import { executeWriteWithMetadata, WriteExecutionMetadataStore } from "./write-execution.ts";
10
11
 
11
12
  function resultText(result: any): string {
@@ -59,6 +60,8 @@ export function renderRichToolResult(
59
60
  );
60
61
  }
61
62
  if (toolName !== "write") return undefined;
63
+ // 让位点:write 归其他扩展时不提供富 diff,交回普通结果行。
64
+ if (!ownsWriteTool()) return undefined;
62
65
 
63
66
  const metadata = writeMetadata.get(context?.toolCallId);
64
67
  if (!metadata) {
@@ -83,23 +86,57 @@ export function renderRichToolResult(
83
86
  );
84
87
  }
85
88
 
86
- function hasExternalWriteOwner(pi: ExtensionAPI): boolean {
89
+ /** write 被其他扩展占用时的来源,用于提示冲突。 */
90
+ export type ExternalWriteOwner = { source: string; path: string };
91
+
92
+ type WriteOwnershipState = {
93
+ /** write 是否由本插件执行;undefined 表示尚未确认,按拥有处理以保持既有行为。 */
94
+ owned?: boolean;
95
+ };
96
+
97
+ function writeOwnership(): WriteOwnershipState {
98
+ return patchRegistry.ensure<WriteOwnershipState>(WRITE_OWNERSHIP_SLOT, () => ({}));
99
+ }
100
+
101
+ /**
102
+ * write 是否由本插件执行。只有注册过 write override 才有执行元数据,
103
+ * 否则渲染层要放行给普通结果行,避免每张卡降级成 "diff unavailable"。
104
+ */
105
+ export function ownsWriteTool(): boolean {
106
+ return writeOwnership().owned !== false;
107
+ }
108
+
109
+ /** 当前占用 write 的其他扩展;builtin 或未注册时返回 undefined。 */
110
+ function findExternalWriteOwner(pi: ExtensionAPI): ExternalWriteOwner | undefined {
87
111
  try {
88
- const tools = pi.getAllTools();
89
- const write = tools.find((tool: any) => tool?.name === "write") as any;
90
- const source = write?.sourceInfo?.source;
91
- return Boolean(write && typeof source === "string" && source !== "builtin");
112
+ const tools = pi.getAllTools() as any[];
113
+ const write = tools?.find((tool: any) => tool?.name === "write") as any;
114
+ const sourceInfo = write?.sourceInfo;
115
+ const source = sourceInfo?.source;
116
+ if (!write || typeof source !== "string" || source === "builtin") return undefined;
117
+ return { source, path: typeof sourceInfo?.path === "string" ? sourceInfo.path : "" };
92
118
  } catch {
93
- // getAllTools is unavailable before the extension runtime is bound.
94
- return false;
119
+ // getAllTools 在扩展运行时绑定前不可用。
120
+ return undefined;
95
121
  }
96
122
  }
97
123
 
98
124
  export function installWriteOverride(
99
125
  pi: ExtensionAPI,
100
126
  store = new WriteExecutionMetadataStore(),
127
+ /** write 已被其他扩展占用时回调一次,调用方据此提示冲突。 */
128
+ onExternalOwner?: (owner: ExternalWriteOwner) => void,
101
129
  ): WriteExecutionMetadataStore {
102
- if (typeof (pi as any).registerTool !== "function" || hasExternalWriteOwner(pi)) return store;
130
+ if (typeof (pi as any).registerTool !== "function") return store;
131
+ const state = writeOwnership();
132
+ const external = findExternalWriteOwner(pi);
133
+ if (external) {
134
+ // 让位:执行与 diff 都归对方,渲染层据 owned=false 走普通结果行。
135
+ state.owned = false;
136
+ onExternalOwner?.(external);
137
+ return store;
138
+ }
139
+ state.owned = true;
103
140
  const nativeWrite = createWriteToolDefinition(process.cwd()) as any;
104
141
  pi.registerTool({
105
142
  ...nativeWrite,
@@ -19,6 +19,7 @@ import {
19
19
  toolCallSummary,
20
20
  type ToolCallSummary,
21
21
  } from "./names.ts";
22
+ import { toolViewportWidth } from "./result.ts";
22
23
  import {
23
24
  patchRegistry,
24
25
  TOOL_GROUPING_GENERATION_KEY as GENERATION_KEY,
@@ -378,7 +379,7 @@ export class ToolGroupComponent extends Container {
378
379
  "",
379
380
  truncateToWidth(
380
381
  ` ${fg(overallColor, "●")} ${label}: ${countText}${nameList} ${hint}`,
381
- width,
382
+ toolViewportWidth(width),
382
383
  "…",
383
384
  ),
384
385
  ];
@@ -411,11 +412,13 @@ export class ToolGroupComponent extends Container {
411
412
  const summary = toolSummary(tool);
412
413
  const prefix = ` ${fg("dim", branch)} ${fg(color, statusIcon(toolStatus))} `;
413
414
  const detail = fg("dim", summary.detail);
414
- const mainWidth = Math.max(0, width - visibleWidth(prefix) - visibleWidth(detail));
415
+ // 与单工具卡标题同宽,宽屏右侧留白一致
416
+ const rowWidth = toolViewportWidth(width);
417
+ const mainWidth = Math.max(0, rowWidth - visibleWidth(prefix) - visibleWidth(detail));
415
418
  lines.push(
416
419
  truncateToWidth(
417
420
  `${prefix}${renderToolSummary(summary, mainWidth, fg)}${detail}`,
418
- width,
421
+ rowWidth,
419
422
  "",
420
423
  ),
421
424
  );
@@ -5,8 +5,13 @@ import { config } from "../../config/config.ts";
5
5
  import { oneLine } from "../../utils/format.ts";
6
6
  import { headTruncateToWidth } from "./result.ts";
7
7
 
8
+ /** inputClip=0 时不设字符上限,交给渲染时的实际宽度截断。 */
9
+ function clipLimit(): number {
10
+ return config.inputClip > 0 ? config.inputClip : Number.POSITIVE_INFINITY;
11
+ }
12
+
8
13
  function clip(value: unknown): string {
9
- return oneLine(value, config.inputClip);
14
+ return oneLine(value, clipLimit());
10
15
  }
11
16
 
12
17
  /** 载荷(入参 JSON / 脚本代码)至少留出这么多宽度才显示,否则整段省略(避免只剩一个孤零零的 " {…")。 */
@@ -62,8 +67,8 @@ export type ToolCallSummary = {
62
67
  };
63
68
 
64
69
  function pathApi(value: string) {
65
- if (win32.isAbsolute(value)) return win32;
66
70
  if (posix.isAbsolute(value)) return posix;
71
+ if (win32.isAbsolute(value)) return win32;
67
72
  return undefined;
68
73
  }
69
74
 
@@ -125,7 +130,7 @@ export function truncatePathToWidth(path: string, width: number): string {
125
130
 
126
131
  /** 路径展示的统一入口:相对化后按配置和当前可用宽度截断。 */
127
132
  export function formatDisplayPath(value: unknown, cwd: string | undefined, width: number): string {
128
- return truncatePathToWidth(displayPath(value, cwd), Math.min(width, config.inputClip));
133
+ return truncatePathToWidth(displayPath(value, cwd), Math.min(width, clipLimit()));
129
134
  }
130
135
 
131
136
  /** 按最终终端宽度渲染摘要;路径摘要不会再被整行头部截断。 */
@@ -134,7 +139,7 @@ export function fitToolCallSummary(summary: ToolCallSummary, width: number): str
134
139
  const prefix = summary.path.prefix;
135
140
  const pathWidth = Math.max(0, width - visibleWidth(prefix) - 1);
136
141
  if (pathWidth <= 0) return headToWidth(prefix, width, "…");
137
- return `${prefix} ${truncatePathToWidth(summary.path.value, Math.min(pathWidth, config.inputClip))}`;
142
+ return `${prefix} ${truncatePathToWidth(summary.path.value, Math.min(pathWidth, clipLimit()))}`;
138
143
  }
139
144
 
140
145
  function pathSummary(
@@ -145,7 +150,7 @@ function pathSummary(
145
150
  ): ToolCallSummary {
146
151
  const path = displayPath(value, cwd);
147
152
  return {
148
- main: `${prefix} ${truncatePathToWidth(path, config.inputClip)}`,
153
+ main: `${prefix} ${truncatePathToWidth(path, clipLimit())}`,
149
154
  detail,
150
155
  path: { prefix, value: path },
151
156
  };
@@ -7,9 +7,15 @@ import { getToolMouseTui } from "../mouse/scroll.ts";
7
7
  import { sanitizeToolResultText } from "../../utils/tool-result-sanitize.ts";
8
8
 
9
9
  const TOOL_VIEWPORT_WIDTH_RATIO = 0.8;
10
+ /** 宽屏右侧留白上限:比例留白超过这么多列时改用固定留白。 */
11
+ const TOOL_VIEWPORT_MAX_GUTTER = 24;
10
12
 
11
13
  export function toolViewportWidth(width: number): number {
12
- return Math.max(1, Math.floor(width * TOOL_VIEWPORT_WIDTH_RATIO));
14
+ return Math.max(
15
+ 1,
16
+ Math.floor(width * TOOL_VIEWPORT_WIDTH_RATIO),
17
+ Math.floor(width) - TOOL_VIEWPORT_MAX_GUTTER,
18
+ );
13
19
  }
14
20
 
15
21
  /** 与默认工具结果相同的一级缩进包装。子组件只扣始终加上的 1 列;↳ 行多出的 2 列由 truncateToWidth 吃掉。 */
@@ -1,3 +1,6 @@
1
+ import { TuiMainScreen } from "@earendil-works/pi-tui";
2
+ import { MAIN_SCREEN_DO_RENDER_PATCH, patchRegistry } from "./patch-keys.ts";
3
+
1
4
  /**
2
5
  * 官方 0.84+ 的 tui 引用是惰性 Proxy(createInteractiveTuiReference):
3
6
  * 函数属性每次 get 都返回新包装,执行时才解析到当前实现。
@@ -9,3 +12,51 @@ export function isLazyProxyTui(tui: any): boolean {
9
12
  const probe = tui.requestRender;
10
13
  return typeof probe === "function" && probe !== tui.requestRender;
11
14
  }
15
+
16
+ type MainScreenDoRenderPatch = {
17
+ active: boolean;
18
+ original: (lines: string[]) => string[];
19
+ installed: (lines: string[]) => string[];
20
+ };
21
+
22
+ /**
23
+ * regular(TuiMainScreen)模式的 doRender 走差分绘制:一旦第一个变化行落在
24
+ * previousViewportTop 之上(终端写不进 scrollback 的区域),只能
25
+ * fullRender(true)——\x1b[2J\x1b[H\x1b[3J 清屏并清掉整段 scrollback。
26
+ * compact 直播式回合的摘要行动画与流式正文渲染在回合工具卡/diff 面板上方;
27
+ * diff 面板一落地把下方内容撑过屏高,之后每帧重绘都命中该分支:scrollback
28
+ * 被反复清空,滚动条回弹、视口钉住,直到输出结束才恢复。
29
+ *
30
+ * 视口上方的行本就无法重写,只剩「清回滚重打」与「scrollback 保留旧文本」
31
+ * 两条路。这里选后者:applyLineResets 是差分比较前最后一个拿到整帧的环节,
32
+ * 把 previousLines 的视口上方前缀直接对齐新帧,差分只看到可写区域,照常增量
33
+ * 绘制。代价仅是 scrollback 里那几行停留在最后一次实际写入的内容。
34
+ */
35
+ export function installMainScreenDoRenderPatch(): void {
36
+ const prototype = TuiMainScreen.prototype as any;
37
+ if (typeof prototype?.applyLineResets !== "function") return;
38
+ const previous = patchRegistry.get<MainScreenDoRenderPatch>(MAIN_SCREEN_DO_RENDER_PATCH);
39
+ if (previous) previous.active = false;
40
+ const original =
41
+ previous && prototype.applyLineResets === previous.installed
42
+ ? previous.original
43
+ : prototype.applyLineResets;
44
+ const patch: MainScreenDoRenderPatch = {
45
+ active: true,
46
+ original,
47
+ installed(this: any, lines: string[]) {
48
+ const result = original.call(this, lines);
49
+ if (!patch.active) return result;
50
+ const prev = this.previousLines;
51
+ if (!Array.isArray(prev)) return result;
52
+ const top = Math.min(
53
+ Math.max(0, Math.floor(Number(this.previousViewportTop) || 0)),
54
+ result.length,
55
+ );
56
+ for (let i = 0; i < top; i++) prev[i] = result[i];
57
+ return result;
58
+ },
59
+ };
60
+ prototype.applyLineResets = patch.installed;
61
+ patchRegistry.install(MAIN_SCREEN_DO_RENDER_PATCH, patch);
62
+ }
@@ -29,6 +29,8 @@ export const COMPONENT_TOOL_RENDER_MODE = Symbol.for("pi.ccstyle.component-tool-
29
29
  export const TOOL_EXPANDED_BACKGROUND_PATCH = Symbol.for(
30
30
  "pi.ccstyle.tool-expanded-background-patch",
31
31
  );
32
+ // write 归属:注册期确认一次,渲染期据此决定是否提供 rich diff。
33
+ export const WRITE_OWNERSHIP_SLOT = Symbol.for("pi.ccstyle.write-ownership");
32
34
 
33
35
  // ── 消息组件补丁 ──
34
36
  export const MESSAGE_DISPLAY_PATCH = Symbol.for("pi.ccstyle.message-display-patch");
@@ -38,12 +40,17 @@ export const TOOL_GROUPING_PATCH_KEY = Symbol.for("pi.ccstyle.tool-grouping-patc
38
40
  export const TOOL_GROUPING_PARENT_KEY = Symbol.for("pi.ccstyle.tool-grouping-parent");
39
41
  export const TOOL_GROUPING_GENERATION_KEY = Symbol.for("pi.ccstyle.tool-grouping-generation");
40
42
 
43
+ // ── regular 主屏渲染补丁 ──
44
+ export const MAIN_SCREEN_DO_RENDER_PATCH = Symbol.for("pi.ccstyle.main-screen-do-render-patch");
45
+
41
46
  // ── 鼠标交互 ──
42
47
  export const TOOL_MOUSE_OWNER_KEY = Symbol.for("pi.ccstyle.tool-mouse-owner");
43
48
  export const TOOL_MOUSE_TUI_SLOT = Symbol.for("pi.ccstyle.tool-mouse-tui");
44
49
  export const SCROLL_BUTTON_STATE_SLOT = Symbol.for("pi.ccstyle.scroll-button-state");
45
50
  export const OFFICIAL_SCROLL_TO_END_KEY = Symbol.for("pi.ccstyle.official-scroll-to-end");
46
51
  export const FLUSH_DOCKED_BASH_PATCH = Symbol.for("pi.ccstyle.flush-docked-bash-patch");
52
+ export const EARLY_STARTUP_HEADER_PATCH = Symbol.for("pi.ccstyle.early-startup-header-patch");
53
+ export const EARLY_STARTUP_HEADER_SWAP_KEY = Symbol.for("pi.ccstyle.early-startup-header-swap");
47
54
  export const TOOL_HOVER_STATE_KEY = Symbol.for("pi.ccstyle.tool-hover-state");
48
55
 
49
56
  // ── rich diff 组件标记 ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",
@@ -56,15 +56,18 @@
56
56
  },
57
57
  "bundledDependencies": [],
58
58
  "peerDependencies": {
59
- "@earendil-works/pi-ai": "^0.84.0",
60
- "@earendil-works/pi-coding-agent": "^0.84.0",
61
- "@earendil-works/pi-tui": "^0.84.0"
59
+ "@earendil-works/pi-ai": "*",
60
+ "@earendil-works/pi-coding-agent": "*",
61
+ "@earendil-works/pi-tui": "*"
62
62
  },
63
63
  "engines": {
64
64
  "node": ">=22.19.0"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@biomejs/biome": "2.5.5",
68
+ "@earendil-works/pi-ai": "0.99.1",
69
+ "@earendil-works/pi-coding-agent": "0.99.1",
70
+ "@earendil-works/pi-tui": "0.99.1",
68
71
  "@types/node": "^26.1.1",
69
72
  "typescript": "^5.9.3"
70
73
  }