pi-one-ui 0.8.0 → 0.9.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/LICENSE +1 -1
  3. package/README.en.md +12 -10
  4. package/README.md +12 -10
  5. package/THIRD-PARTY-NOTICES.md +98 -0
  6. package/extensions/app/config/renderer.ts +1 -12
  7. package/extensions/app/config/shell.ts +5 -5
  8. package/extensions/app/runtime/tui-runtime.ts +6 -6
  9. package/extensions/app/settings/controller.ts +1 -1
  10. package/extensions/layouts/overlay/settings-panel.ts +9 -8
  11. package/extensions/layouts/overlay/settings-previews.ts +1 -1
  12. package/extensions/layouts/{context → transcript}/index.ts +8 -8
  13. package/extensions/layouts/{context → transcript}/renderer/compact-mode.ts +4 -4
  14. package/extensions/layouts/{context → transcript}/renderer/default-mode.ts +37 -7
  15. package/extensions/layouts/{context → transcript}/renderer/index.ts +8 -8
  16. package/extensions/layouts/{context → transcript}/renderer/mouse/hover.ts +0 -4
  17. package/extensions/layouts/{context → transcript}/renderer/mouse/interaction.ts +104 -93
  18. package/extensions/layouts/{context → transcript}/renderer/mouse/layout.ts +0 -4
  19. package/extensions/layouts/transcript/renderer/mouse/scroll.ts +35 -0
  20. package/extensions/layouts/{context → transcript}/renderer/tool/message-display.ts +1 -1
  21. package/extensions/layouts/{context → transcript}/renderer/tool/names.ts +19 -2
  22. package/extensions/layouts/{context/renderer/context-refresh.ts → transcript/renderer/transcript-refresh.ts} +6 -6
  23. package/extensions/layouts/{context → transcript}/thinking/compact-thinking.ts +4 -4
  24. package/extensions/tools/component-tree.ts +1 -1
  25. package/extensions/tools/patch-keys.ts +0 -3
  26. package/package.json +3 -2
  27. package/extensions/layouts/context/renderer/mouse/scroll.ts +0 -216
  28. /package/extensions/app/config/{context.ts → user-messages.ts} +0 -0
  29. /package/extensions/layouts/{context → transcript}/message/user-message-osc.ts +0 -0
  30. /package/extensions/layouts/{context → transcript}/message/user-message-styles.ts +0 -0
  31. /package/extensions/layouts/{context → transcript}/message/user-message.ts +0 -0
  32. /package/extensions/layouts/{context → transcript}/renderer/markdown-enhance.ts +0 -0
  33. /package/extensions/layouts/{context → transcript}/renderer/mouse/packets.ts +0 -0
  34. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/ATTRIBUTION.md +0 -0
  35. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/ansi-utils.ts +0 -0
  36. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-component.ts +0 -0
  37. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-edit-render.ts +0 -0
  38. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-header.ts +0 -0
  39. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-highlight.ts +0 -0
  40. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-inline.ts +0 -0
  41. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-layout.ts +0 -0
  42. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-limits.ts +0 -0
  43. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-palette.ts +0 -0
  44. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-parse.ts +0 -0
  45. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-presentation.ts +0 -0
  46. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-renderer.ts +0 -0
  47. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-text.ts +0 -0
  48. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/diff-write-render.ts +0 -0
  49. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/index.ts +0 -0
  50. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/line-width-safety.ts +0 -0
  51. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/shiki-highlight.ts +0 -0
  52. /package/extensions/layouts/{context → transcript}/renderer/tool/diff/write-execution.ts +0 -0
  53. /package/extensions/layouts/{context → transcript}/renderer/tool/grouping.ts +0 -0
  54. /package/extensions/layouts/{context → transcript}/renderer/tool/result.ts +0 -0
  55. /package/extensions/layouts/{context → transcript}/renderer/tool/show-more-hint.ts +0 -0
  56. /package/extensions/layouts/{context → transcript}/renderer/tool/toggle-render-cache.ts +0 -0
  57. /package/extensions/layouts/{context → transcript}/summary/core.ts +0 -0
  58. /package/extensions/layouts/{context → transcript}/summary/index.ts +0 -0
@@ -5,7 +5,12 @@
5
5
  * 生命周期对齐 compact-mode:installDefaultMode → hooks.shutdown。
6
6
  */
7
7
  import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
8
- import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
8
+ import {
9
+ Text,
10
+ truncateToWidth,
11
+ visibleWidth,
12
+ wrapTextWithAnsi,
13
+ } from "@earendil-works/pi-tui";
9
14
  import {
10
15
  type CompactStyleMode,
11
16
  config,
@@ -271,10 +276,18 @@ function createPolishedTool(
271
276
  visualState === "success"
272
277
  ? `${BRIGHT_GREEN}${rawIcon}${ANSI_FG_RESET}`
273
278
  : theme.fg(toolIconColor(context), rawIcon);
279
+ const summaryTitle =
280
+ label === toolName ? humanizeToolLabel(label) : label;
274
281
  const summary = toolCallSummary(toolName, args, {
275
- title: label === toolName ? humanizeToolLabel(label) : label,
282
+ title: summaryTitle,
276
283
  variant: "default",
277
284
  });
285
+ const expanded = Boolean(context?.expanded);
286
+ // 展开且仍在执行的卡展示完整命令;折叠卡与已结束的卡继续用截断摘要。
287
+ const fullCallText =
288
+ expanded && isPending
289
+ ? `${toolCallSummary(toolName, args, { title: summaryTitle, variant: "default", fullInput: true }).main}${summary.detail}`
290
+ : "";
278
291
  let writeStatsText = "";
279
292
  let writeStatsStyled = "";
280
293
  if (toolName === "write" && visualState === "success") {
@@ -293,23 +306,40 @@ function createPolishedTool(
293
306
  const extraStyled = writeStatsStyled || theme.fg("dim", summary.detail);
294
307
  let cachedWidth: number | undefined;
295
308
  let cachedLine: string | undefined;
296
- const expanded = Boolean(context?.expanded);
309
+ let cachedLines: string[] | undefined;
297
310
  return {
298
311
  render(width: number) {
299
- if (cachedLine !== undefined && cachedWidth === width)
300
- return [cachedLine];
312
+ if (cachedLines !== undefined && cachedWidth === width)
313
+ return cachedLines;
301
314
  const viewportWidth = toolViewportWidth(width);
302
315
  // 展开态贴左(外层 Box 已 pad 1);折叠 self-shell 保留 1 格前导空格
303
316
  const lead = expanded ? "" : " ";
317
+ cachedWidth = width;
318
+ if (fullCallText) {
319
+ // 完整文本按换行与视口宽度折行,续行对齐命令起始列
320
+ const prefix = `${lead}${icon} `;
321
+ const indent = " ".repeat(visibleWidth(prefix));
322
+ cachedLines = wrapTextWithAnsi(
323
+ fullCallText,
324
+ Math.max(1, viewportWidth - visibleWidth(prefix)),
325
+ ).map((line, index) =>
326
+ truncateToWidth(
327
+ `${index === 0 ? prefix : indent}${theme.fg("toolTitle", line)}`,
328
+ viewportWidth,
329
+ "",
330
+ ),
331
+ );
332
+ return cachedLines;
333
+ }
304
334
  const callWidth = Math.max(
305
335
  0,
306
336
  viewportWidth - visibleWidth(icon) - 1 - (expanded ? 0 : 1),
307
337
  );
308
338
  const mainWidth = Math.max(0, callWidth - visibleWidth(extraText));
309
- cachedWidth = width;
310
339
  // 纯文本先截断再着色(省略号不带 ANSI);从头截断,与多 tool 一致
311
340
  cachedLine = `${lead}${icon} ${theme.fg("toolTitle", headTruncateToWidth(summary.main, mainWidth))}${extraStyled}`;
312
- return [truncateToWidth(cachedLine, viewportWidth, "")];
341
+ cachedLines = [truncateToWidth(cachedLine, viewportWidth, "")];
342
+ return cachedLines;
313
343
  },
314
344
  invalidate() {},
315
345
  };
@@ -62,7 +62,7 @@ import {
62
62
 
63
63
  let compactModeHooks: CompactModeHooks | undefined;
64
64
 
65
- function refreshCurrentContext(
65
+ function refreshCurrentTranscript(
66
66
  ctx?: any,
67
67
  toolGrouping?: ToolGroupingHooks,
68
68
  render?: ToolMouseServices["render"],
@@ -104,15 +104,15 @@ function applyStyleMode(
104
104
  tui.terminal?.write?.(TOOL_MOUSE_DISABLE);
105
105
  }
106
106
  } else if (mode === "compact") {
107
- // Enter compact mode after collecting the current context and syncing ownership.
107
+ // Enter compact mode after collecting the current transcript and syncing ownership.
108
108
  syncCompactMode(ctx);
109
109
  }
110
110
  // Reshape immediately and once more after the panel/custom UI unmounts and
111
- // the main context is mounted again.
112
- refreshCurrentContext(ctx, toolGrouping, render);
111
+ // the main transcript is mounted again.
112
+ refreshCurrentTranscript(ctx, toolGrouping, render);
113
113
  scheduleSessionRender(() => {
114
114
  if (mode === "compact") syncCompactMode(ctx);
115
- refreshCurrentContext(ctx, toolGrouping, render);
115
+ refreshCurrentTranscript(ctx, toolGrouping, render);
116
116
  });
117
117
  ctx.ui.notify(`Claude Code style: ${mode}`, "info");
118
118
  }
@@ -203,7 +203,7 @@ export default function (
203
203
  // 配置(如 expandedPreviewMaxLines / excludeRenderers)变化可能改变
204
204
  // 渲染结果:丢弃工具卡跨 toggle 缓存,下次 updateDisplay 重建。
205
205
  installation?.toggleRenderCache.clear();
206
- refreshCurrentContext(
206
+ refreshCurrentTranscript(
207
207
  ctx,
208
208
  installation?.toolGrouping,
209
209
  options.services?.render,
@@ -243,7 +243,7 @@ export default function (
243
243
  hooks.toolGrouping.setTheme(ctx.ui.theme);
244
244
  hooks.toggleRenderCache.setTheme(ctx.ui.theme);
245
245
  setMessageDisplayTheme(ctx.ui.theme);
246
- // Collect the resumed context before syncing compact patches and expansion state.
246
+ // Collect the resumed transcript before syncing compact patches and expansion state.
247
247
  syncCompactMode(ctx);
248
248
  // compact-thinking 的 session_start 处理在本 handler 之后执行(在其之上再装
249
249
  // 一层 updateContent);延迟再同步一次,保证 compact 补丁最终位于外层。
@@ -253,7 +253,7 @@ export default function (
253
253
 
254
254
  pi.on("session_compact", async (event, ctx) => {
255
255
  const hooks = ensureTuiInstallation(ctx);
256
- // Compaction rebuilds the context without session_start. Rebind after
256
+ // Compaction rebuilds the transcript without session_start. Rebind after
257
257
  // other TUI extensions may have replaced the root input dispatcher.
258
258
  if (ctx?.mode === "tui" && ctx?.hasUI)
259
259
  installToolMouseInteraction(ctx, mouseOwner, options.services);
@@ -12,7 +12,6 @@ import {
12
12
  type ToolIoSection,
13
13
  } from "../tool/result.ts";
14
14
  import { type ComponentRowHit, componentAtLocalRow } from "./layout.ts";
15
- import { setScrollButtonHovered } from "./scroll.ts";
16
15
 
17
16
  type SharedToolHoverState = { toolCallId: string | null };
18
17
 
@@ -132,7 +131,6 @@ export function cachedFullscreenComponentAtRow(
132
131
 
133
132
  /** fullscreen 鼠标悬停目标。 */
134
133
  export type FullscreenHoverTarget =
135
- | { kind: "button" }
136
134
  | { kind: "group"; component: ToolGroupComponent }
137
135
  | { kind: "thinking"; component: ThinkingPreviewBlock }
138
136
  | { kind: "message"; component: any }
@@ -169,7 +167,5 @@ export function applyFullscreenHover(
169
167
  const nextView = target?.kind === "tool" ? target.view : null;
170
168
  const nextSection = target?.kind === "tool" ? target.section : null;
171
169
  if (setHoveredToolIo(nextView, nextSection)) changed = true;
172
- const nextButton = target?.kind === "button";
173
- if (setScrollButtonHovered(nextButton)) changed = true;
174
170
  if (changed) tui.requestRender?.();
175
171
  }
@@ -1,4 +1,4 @@
1
- import { config } from "../../../../app/config/renderer.ts";
1
+ import { getKeybindings } from "@earendil-works/pi-tui";
2
2
  import {
3
3
  type InputRouter,
4
4
  inputRouter,
@@ -63,20 +63,9 @@ import {
63
63
  } from "./packets.ts";
64
64
  import {
65
65
  fullscreenLazyTui,
66
- getScrollButtonVisible,
67
- getScrollButtonWidget,
68
66
  getToolMouseTui,
69
- hideScrollButton,
70
- isScrollBottomInput,
71
- renderScrollButton,
72
- resetScrollButtonState,
73
- scheduleScrollButtonSync,
74
- setScrollButtonHovered,
75
- setScrollButtonVisible,
76
- setScrollButtonWidget,
77
67
  setToolMouseTui,
78
68
  toolMouseInteractionActive,
79
- updateScrollButtonFromInput,
80
69
  } from "./scroll.ts";
81
70
 
82
71
  type FrameToolRender = {
@@ -86,7 +75,7 @@ type FrameToolRender = {
86
75
  };
87
76
 
88
77
  type InteractionRegion = {
89
- kind: "collapsed-hint" | "expanded-card" | "show-more" | "scroll-bottom";
78
+ kind: "collapsed-hint" | "expanded-card" | "show-more";
90
79
  row: number;
91
80
  startCol: number;
92
81
  endCol: number;
@@ -101,6 +90,11 @@ const TOOL_MOUSE_WIDGET_KEY = "oneui-tool-mouse";
101
90
  const TOOL_MOUSE_MOTION_ENABLE = "\x1b[?1003h\x1b[?1006h";
102
91
  const TOOL_MOUSE_MOTION_DISABLE = "\x1b[?1003l";
103
92
  const FULLSCREEN_MOTION_ENABLED = Symbol("pi-one-ui.fullscreen-motion-enabled");
93
+ const FULLSCREEN_SCROLL_END_STYLE_PATCH = Symbol(
94
+ "pi-one-ui.fullscreen-scroll-end-style-patch",
95
+ );
96
+ /** 指示条 hover 状态:样式函数与命中检测同模块,模块级绑定即可。 */
97
+ let scrollEndIndicatorHovered = false;
104
98
  const DEFAULT_TOOL_MOUSE_OWNER = {};
105
99
  export const TOOL_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
106
100
 
@@ -130,7 +124,6 @@ function interactionRegionAt(packet: SgrMousePacket): InteractionRegion | null {
130
124
  );
131
125
  return (
132
126
  matches.find((region) => region.kind === "show-more") ??
133
- matches.find((region) => region.kind === "scroll-bottom") ??
134
127
  matches.find((region) => region.kind === "collapsed-hint") ??
135
128
  matches.find((region) => region.kind === "expanded-card") ??
136
129
  null
@@ -156,8 +149,6 @@ function tryOpenToolIoShowMore(region: InteractionRegion): boolean {
156
149
  function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
157
150
  if ((packet.code & 32) === 0 || packet.final !== "M") return;
158
151
  const region = interactionRegionAt(packet);
159
- const nextScrollButtonHovered = region?.kind === "scroll-bottom";
160
- const scrollButtonChanged = setScrollButtonHovered(nextScrollButtonHovered);
161
152
  const component = region?.component;
162
153
  const nextToolCallId =
163
154
  region?.kind === "collapsed-hint" ? (component?.toolCallId ?? null) : null;
@@ -169,7 +160,6 @@ function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
169
160
  const changed = nextToolCallId !== sharedToolHoverState().toolCallId;
170
161
  setHoveredToolCallId(nextToolCallId);
171
162
  if (
172
- scrollButtonChanged ||
173
163
  setHoveredToolIo(nextIoView, nextIoSection) ||
174
164
  setHoveredToolGroup(nextGroup) ||
175
165
  changed
@@ -218,7 +208,6 @@ function collapseExpandedCard(tui: any, card: any): boolean {
218
208
  function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
219
209
  const region = interactionRegionAt(packet);
220
210
  if (!region) return false;
221
- if (region.kind === "scroll-bottom") return false;
222
211
  if (region.kind === "show-more") return tryOpenToolIoShowMore(region);
223
212
  const component = region.component;
224
213
  if (!component) return false;
@@ -255,13 +244,6 @@ function ensureFullscreenToolMouseMotion(tui: any): void {
255
244
  releaseFullscreenToolMouseMotion(tui);
256
245
  return;
257
246
  }
258
- // 面板改 scrollStepLines 后,下一帧渲染即同步(restore 仍按 original 恢复)。
259
- if (
260
- typeof tui.wheelScrollLines === "number" &&
261
- tui.wheelScrollLines !== config.scrollStepLines
262
- ) {
263
- tui.wheelScrollLines = config.scrollStepLines;
264
- }
265
247
  if (
266
248
  !toolMouseInteractionActive() ||
267
249
  tui.mouseEnabled === false ||
@@ -299,14 +281,11 @@ function releaseFullscreenToolMouseMotion(tui?: any): void {
299
281
  }
300
282
 
301
283
  const FULLSCREEN_VIEWPORT_PATCH = Symbol("pi-one-ui.fullscreen-viewport-patch");
302
- const FULLSCREEN_WHEEL_SCROLL_ORIGINAL = Symbol(
303
- "pi-one-ui.fullscreen-wheel-scroll-original",
304
- );
305
284
 
306
285
  /**
307
286
  * 官方 fullscreen 工具卡点击:collapsed hint 单击展开
308
287
  * (有且仅保持一个展开:展开前收起其他工具卡),expanded 整卡双击收起,
309
- * 截断头 show-more 单击打开全量预览;回到底部按钮 scrollToBottom。
288
+ * 截断头 show-more 单击打开全量预览。
310
289
  * 滚动条列、含 OSC8 链接行、非工具区域、展开卡单击放行官方。
311
290
  */
312
291
  function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
@@ -316,6 +295,8 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
316
295
  const x = packet.col - 1;
317
296
  const y = packet.row - 1;
318
297
  if (isScrollbarColumnAt(layout, x)) return false;
298
+ // 指示条覆盖层优先:命中区放行官方,由官方 rect 命中触发 scrollToBottom。
299
+ if (isScrollEndIndicatorAt(tui, x, y)) return false;
319
300
  const hit = fullscreenLeafAt(layout, x, y);
320
301
  if (!hit) return false;
321
302
  const width = Math.max(1, Number(tui.terminal?.columns) || 80);
@@ -330,12 +311,6 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
330
311
  if (!target) return false;
331
312
  const component = target.component;
332
313
  const card = target.group ?? component;
333
- // 回到底部按钮:按组件引用命中,不依赖渲染行缓存。
334
- if (getScrollButtonVisible() && component === getScrollButtonWidget()) {
335
- tui.scrollToBottom?.();
336
- hideScrollButton(tui);
337
- return true;
338
- }
339
314
  const line = hit.box.lines?.[hit.localRow];
340
315
  if (typeof line !== "string" || /\x1b]8;[^;]*;/.test(line)) return false;
341
316
  const isTool = isToolExecutionComponent(component);
@@ -401,30 +376,96 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
401
376
 
402
377
  /**
403
378
  * fullscreen 鼠标悬停:collapsed 卡 [click to show more] hint、
404
- * expanded 卡截断头 show-more、回到底部按钮。motion 不 consume,官方链照常。
379
+ * expanded 卡截断头 show-more、回到底部指示条。motion 不 consume,官方链照常。
405
380
  */
381
+ function formatKeyDisplay(keys: string[]): string {
382
+ return keys
383
+ .join("/")
384
+ .split("/")
385
+ .map((part) =>
386
+ part
387
+ .split("+")
388
+ .map((piece) =>
389
+ piece.length <= 1
390
+ ? piece.toUpperCase()
391
+ : `${piece[0]?.toUpperCase() ?? ""}${piece.slice(1)}`,
392
+ )
393
+ .join("+"),
394
+ )
395
+ .join("/");
396
+ }
397
+
398
+ /** 回到底部快捷键(tui.altScreen.bottom)显示文本;未配置时省略快捷键段。 */
399
+ function scrollEndShortcutText(): string {
400
+ const keys = getKeybindings().getKeys("tui.altScreen.bottom");
401
+ return keys.length > 0 ? formatKeyDisplay(keys) : "";
402
+ }
403
+
404
+ /** pi-one-ui 按钮样式:[ ↓ Back to bottom · <key> ],accent 色、hover 转 text 色。 */
405
+ function renderScrollEndIndicatorLabel(theme: any): string {
406
+ const shortcut = scrollEndShortcutText();
407
+ const label = `[ ↓ Back to bottom${shortcut ? ` · ${shortcut}` : ""} ]`;
408
+ return theme.fg(scrollEndIndicatorHovered ? "text" : "accent", label);
409
+ }
410
+
411
+ /** 指示条是 composite 层覆盖物(不在布局树内),按官方 rect 判定命中。 */
412
+ function isScrollEndIndicatorAt(tui: any, x: number, y: number): boolean {
413
+ const rect = tui.scrollToEndIndicatorRect;
414
+ return Boolean(
415
+ rect && y === rect.row && x >= rect.column && x < rect.column + rect.width,
416
+ );
417
+ }
418
+
419
+ /** 指示条 hover 同步;返回是否悬停在指示条上(命中时优先于下方组件)。 */
420
+ function updateScrollEndIndicatorHover(
421
+ tui: any,
422
+ x: number,
423
+ y: number,
424
+ ): boolean {
425
+ const hovered = isScrollEndIndicatorAt(tui, x, y);
426
+ if (hovered !== scrollEndIndicatorHovered) {
427
+ scrollEndIndicatorHovered = hovered;
428
+ tui.requestRender?.();
429
+ }
430
+ return hovered;
431
+ }
432
+
433
+ /**
434
+ * 覆盖官方 scrollToEndIndicator 样式函数(实例字段,官方每帧调用取标签文本;
435
+ * 显示条件、居中、点击命中、scrollToBottom 均保留官方机制)。惰性 Proxy 的
436
+ * get 会包装函数属性,保存到 Symbol 的是等价转发包装,恢复后行为不变。
437
+ */
438
+ function installScrollEndIndicatorStyle(tui: any, theme: any): void {
439
+ if (tui[FULLSCREEN_SCROLL_END_STYLE_PATCH]) return;
440
+ const original = tui.scrollToEndIndicator;
441
+ if (typeof original !== "function") return;
442
+ tui[FULLSCREEN_SCROLL_END_STYLE_PATCH] = original;
443
+ tui.scrollToEndIndicator = () => renderScrollEndIndicatorLabel(theme);
444
+ }
445
+
446
+ function restoreScrollEndIndicatorStyle(tui: any): void {
447
+ const original = tui?.[FULLSCREEN_SCROLL_END_STYLE_PATCH];
448
+ if (typeof original !== "function") return;
449
+ tui.scrollToEndIndicator = original;
450
+ tui[FULLSCREEN_SCROLL_END_STYLE_PATCH] = undefined;
451
+ scrollEndIndicatorHovered = false;
452
+ }
453
+
406
454
  function handleFullscreenToolHover(tui: any, packet: SgrMousePacket): void {
407
455
  if (packet.final !== "M") return;
408
456
  const layout = tui.currentLayout;
409
457
  if (!layout?.root) return;
410
458
  const x = packet.col - 1;
411
459
  const y = packet.row - 1;
460
+ if (updateScrollEndIndicatorHover(tui, x, y)) {
461
+ applyFullscreenHover(tui, null);
462
+ return;
463
+ }
412
464
  let target: FullscreenHoverTarget | null = null;
413
465
  const hit = fullscreenLeafAt(layout, x, y);
414
466
  if (hit) {
415
467
  const line = hit.box.lines?.[hit.localRow];
416
- // 回到底部按钮:渲染行文本 + 列区间识别(零组件树开销)。
417
- if (
418
- typeof line === "string" &&
419
- getScrollButtonVisible() &&
420
- line.includes("[ ↓")
421
- ) {
422
- const plain = stripTerminalSequencesPreservingLayout(line);
423
- const idx = plain.indexOf("[ ↓");
424
- if (idx >= 0 && x >= idx && x <= idx + plain.length - 1) {
425
- target = { kind: "button" };
426
- }
427
- } else if (typeof line === "string" && !/\x1b]8;/.test(line)) {
468
+ if (typeof line === "string" && !/\x1b]8;/.test(line)) {
428
469
  const width = Math.max(1, Number(tui.terminal?.columns) || 80);
429
470
  const contentWidth = fullscreenContentWidth(hit.box, width);
430
471
  // 与点击共用同一定位算法,避免 hover 自建行段与真实组件树错位。
@@ -489,17 +530,9 @@ function patchFullscreenViewportInput(tui: any): void {
489
530
  const proto = Object.getPrototypeOf(tui);
490
531
  const original = proto?.handleViewportInput;
491
532
  if (typeof original !== "function") return;
492
- // 官方原生 routeWheel 已完整处理嵌套 ScrollView;只调整默认步进(config.scrollStepLines)。
493
- if (typeof tui.wheelScrollLines === "number") {
494
- tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL] = tui.wheelScrollLines;
495
- tui.wheelScrollLines = config.scrollStepLines;
496
- }
497
533
  tui[FULLSCREEN_VIEWPORT_PATCH] = true;
498
534
  tui.handleViewportInput = function (this: any, data: string) {
499
535
  if (toolMouseInteractionActive() && tui.mode === "fullscreen") {
500
- // 滚动输入(wheel/pageUp/end 等)后同步回到底部按钮显隐;
501
- // 官方 viewport 会消费键盘,扩展监听器无法补偿,必须在这里调度。
502
- scheduleScrollButtonSync(tui, data);
503
536
  const packets = parseSgrMousePackets(data);
504
537
  // 官方 fullscreen 会消费全部鼠标;文本预览 overlay 活动时放行给 focused
505
538
  // custom component,使 [esc] 点击和滚轮可用。
@@ -529,11 +562,6 @@ function restoreFullscreenViewportInput(tui: any): void {
529
562
  if (typeof proto?.handleViewportInput === "function") {
530
563
  tui.handleViewportInput = proto.handleViewportInput;
531
564
  }
532
- const originalWheelScrollLines = tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL];
533
- if (typeof originalWheelScrollLines === "number") {
534
- tui.wheelScrollLines = originalWheelScrollLines;
535
- tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL] = undefined;
536
- }
537
565
  tui[FULLSCREEN_VIEWPORT_PATCH] = false;
538
566
  }
539
567
 
@@ -857,24 +885,12 @@ function handleToolMouseInput(data: string): { consume: true } | undefined {
857
885
  if (!getToolMouseTui()) return undefined;
858
886
  // 惰性 Proxy fullscreen:鼠标由 handleViewportInput 包装消费(官方链之前),
859
887
  // 此处只处理键盘(鼠标事件在官方 listener 已被 consume,到不了这里)。
860
- if (fullscreenLazyTui(getToolMouseTui())) {
861
- scheduleScrollButtonSync(getToolMouseTui(), data);
862
- if (isScrollBottomInput(data)) {
863
- getToolMouseTui().scrollToBottom?.();
864
- hideScrollButton(getToolMouseTui());
865
- return { consume: true };
866
- }
867
- return undefined;
868
- }
869
- updateScrollButtonFromInput(getToolMouseTui(), data);
888
+ if (fullscreenLazyTui(getToolMouseTui())) return undefined;
870
889
  // Off mode restores native input: wheel keeps scrolling through Pi's normal
871
890
  // dispatcher, while hover/click affordances are entirely inactive.
872
891
  if (!toolMouseInteractionActive()) return undefined;
873
892
  const packets = parseSgrMousePackets(data);
874
- if (!packets) {
875
- scheduleScrollButtonSync(getToolMouseTui(), data);
876
- return undefined;
877
- }
893
+ if (!packets) return undefined;
878
894
 
879
895
  let consumed = false;
880
896
  for (const packet of packets) {
@@ -887,7 +903,6 @@ function handleToolMouseInput(data: string): { consume: true } | undefined {
887
903
 
888
904
  // Let scrolling, motion, release, and clicks outside tool results reach the
889
905
  // normal TUI input chain (including other extensions such as pi-zentui).
890
- scheduleScrollButtonSync(getToolMouseTui(), data);
891
906
  return consumed ? { consume: true } : undefined;
892
907
  }
893
908
 
@@ -923,26 +938,24 @@ export function teardownToolMouseInteraction(
923
938
  }
924
939
  restoreToolMouseRenderPatch();
925
940
  restoreFullscreenViewportInput(getToolMouseTui());
926
- resetScrollButtonState();
941
+ restoreScrollEndIndicatorStyle(getToolMouseTui());
927
942
  setToolMouseTui(null);
928
943
  toolMouseUi = null;
929
944
  patchRegistry.dispose(TOOL_MOUSE_OWNER_KEY, owner);
930
945
  if (toolMouseInstallationOwner === owner) toolMouseInstallationOwner = null;
931
946
  }
932
947
 
933
- /** off 模式清理:清空 hover 与回到底部按钮状态(跨模块 rebind 统一经由此函数)。 */
948
+ /** off 模式清理:清空 hover 状态(跨模块 rebind 统一经由此函数)。 */
934
949
  export function resetToolHoverState(): void {
935
950
  setHoveredToolCallId(null);
936
951
  setHoveredThinking(null);
937
952
  setHoveredMessageDisplay(null);
938
953
  setHoveredCompactAssistant(null);
939
- setScrollButtonVisible(false);
940
- setScrollButtonHovered(false);
941
954
  releaseFullscreenToolMouseMotion(getToolMouseTui());
942
955
  }
943
956
 
944
957
  /**
945
- * Installs the Context mouse route through the shared input router.
958
+ * Installs the transcript mouse route through the shared input router.
946
959
  *
947
960
  * @param ctx Active TUI extension context.
948
961
  * @param owner Ownership token used to make teardown safe across reloads.
@@ -983,26 +996,24 @@ export function installToolMouseInteraction(
983
996
  if (isLazyProxyTui(tui)) {
984
997
  patchFullscreenViewportInput(tui);
985
998
  ensureFullscreenToolMouseMotion(tui);
986
- setScrollButtonWidget({
987
- render: (width: number) => {
999
+ installScrollEndIndicatorStyle(tui, theme);
1000
+ return {
1001
+ render: () => {
1002
+ // 惰性 Proxy 下 renderer 可能被替换,每帧补装 viewport 包装与 motion。
988
1003
  patchFullscreenViewportInput(tui);
989
1004
  ensureFullscreenToolMouseMotion(tui);
990
- return renderScrollButton(width, theme);
1005
+ installScrollEndIndicatorStyle(tui, theme);
1006
+ return [];
991
1007
  },
992
1008
  invalidate() {},
993
- });
994
- return getScrollButtonWidget();
1009
+ };
995
1010
  }
996
1011
  // Wrap doRender to capture the live frame for tool click/hover mapping.
997
1012
  patchToolMouseMotionAfterRender(tui);
1013
+ installScrollEndIndicatorStyle(tui, theme);
998
1014
  if (toolMouseInteractionActive())
999
1015
  tui?.terminal?.write?.(TOOL_MOUSE_MOTION_ENABLE);
1000
- const widget = {
1001
- render: (width: number) => renderScrollButton(width, theme),
1002
- invalidate() {},
1003
- };
1004
- setScrollButtonWidget(widget);
1005
- return widget;
1016
+ return { render: () => [], invalidate() {} };
1006
1017
  });
1007
1018
  const removeInput = services.input.bind(
1008
1019
  ctx.ui,
@@ -1026,7 +1037,7 @@ export function scheduleSessionRender(refresh?: () => void): void {
1026
1037
  const tui = getToolMouseTui();
1027
1038
  if (!tui || typeof tui.requestRender !== "function") return;
1028
1039
  if (sessionRenderTimer) clearTimeout(sessionRenderTimer);
1029
- // Restored context components are populated at different points for startup, reload,
1040
+ // Restored transcript components are populated at different points for startup, reload,
1030
1041
  // and session replacement. Repaint after session_start and the surrounding UI
1031
1042
  // rebuild finish so messages are not left hidden until the next terminal input.
1032
1043
  sessionRenderTimer = setTimeout(() => {
@@ -7,7 +7,6 @@ import {
7
7
  isToolExecutionComponent,
8
8
  stripTerminalSequencesPreservingLayout,
9
9
  } from "./packets.ts";
10
- import { getScrollButtonWidget } from "./scroll.ts";
11
10
 
12
11
  export type ComponentRowHit = {
13
12
  component: any;
@@ -156,9 +155,6 @@ export function componentAtLocalRow(
156
155
  if (isMessageDisplayComponent(component)) {
157
156
  return { component, row: localRow };
158
157
  }
159
- if (component === getScrollButtonWidget()) {
160
- return { component, row: localRow };
161
- }
162
158
  if (!Array.isArray(component.children)) return null;
163
159
  let offset = 0;
164
160
  for (const child of component.children) {
@@ -0,0 +1,35 @@
1
+ import { config } from "../../../../app/config/renderer.ts";
2
+ import { isLazyProxyTui } from "../../../../tools/fullscreen-detect.ts";
3
+ import {
4
+ patchRegistry,
5
+ TOOL_MOUSE_TUI_SLOT,
6
+ } from "../../../../tools/patch-keys.ts";
7
+
8
+ /**
9
+ * 当前安装的 tui 宿主。由 mouse-interaction 经 setToolMouseTui 维护;
10
+ * 跨模块一律经绑定/setter 访问。
11
+ *
12
+ * 状态镜像到 globalThis(Symbol 槽):jiti 转译下经 re-export 链读取的
13
+ * 模块级 let 绑定是初始值快照(死绑定,实测恒 null),函数调用才是活引用。
14
+ * 跨模块读取一律用 getToolMouseTui(),避免拿到加载时的快照。
15
+ */
16
+ patchRegistry.ensure(TOOL_MOUSE_TUI_SLOT, () => null);
17
+ export let toolMouseTui: any = null;
18
+ export function getToolMouseTui(): any {
19
+ return patchRegistry.get(TOOL_MOUSE_TUI_SLOT);
20
+ }
21
+ export function setToolMouseTui(tui: any): void {
22
+ toolMouseTui = tui;
23
+ patchRegistry.install(TOOL_MOUSE_TUI_SLOT, tui);
24
+ }
25
+
26
+ // 交互开关只取决于配置模式:原实现按 isLazyProxyTui(toolMouseTui) 分两分支,
27
+ // 两分支恒真(0.84+ 惰性 Proxy 下判定不再影响开关),折叠为单条件。
28
+ export function toolMouseInteractionActive(): boolean {
29
+ return config.mode !== "off";
30
+ }
31
+
32
+ /** 惰性 Proxy 官方 fullscreen(TuiAltScreen)判定。 */
33
+ export function fullscreenLazyTui(tui: any): boolean {
34
+ return isLazyProxyTui(tui) && tui.mode === "fullscreen";
35
+ }
@@ -173,7 +173,7 @@ export function isMessageDisplayComponent(value: any): boolean {
173
173
  }
174
174
 
175
175
  /**
176
- * Refreshes mounted context message components after a mode change.
176
+ * Refreshes mounted transcript message components after a mode change.
177
177
  */
178
178
  export function refreshMessageDisplays(root: any): void {
179
179
  walkComponentTree(root, (value: any) => {
@@ -1,8 +1,21 @@
1
1
  import { config } from "../../../../app/config/renderer.ts";
2
2
  import { oneLine } from "../../../../tools/format.ts";
3
+ import { sanitizeToolResultText } from "../../../../tools/tool-result-sanitize.ts";
3
4
 
4
- function clip(value: unknown): string {
5
- return oneLine(value, config.toolInputNameLength);
5
+ /** 展开输入与 `formatToolInputArgs` 共用同一个字符数上限。 */
6
+ const MAX_FULL_INPUT_CHARS = 8_000;
7
+
8
+ /**
9
+ * 输入摘要的文本处理。折叠摘要压成一行并按 toolInputNameLength 截断;
10
+ * fullInput 保留完整内容与换行,供展开卡展示完整命令使用。
11
+ */
12
+ function clipSummaryInput(value: unknown, fullInput: boolean): string {
13
+ if (!fullInput) return oneLine(value, config.toolInputNameLength);
14
+ const raw = String(value ?? "");
15
+ const text = sanitizeToolResultText(raw, MAX_FULL_INPUT_CHARS)
16
+ .replace(/\t/g, " ")
17
+ .trim();
18
+ return raw.length > MAX_FULL_INPUT_CHARS ? `${text}…` : text;
6
19
  }
7
20
 
8
21
  /**
@@ -31,6 +44,8 @@ export type ToolCallSummaryOptions = {
31
44
  title?: string;
32
45
  /** 文案变体;缺省 "default"。 */
33
46
  variant?: ToolCallSummaryVariant;
47
+ /** true 时返回未截断且保留换行的完整输入;缺省 false。 */
48
+ fullInput?: boolean;
34
49
  };
35
50
 
36
51
  /**
@@ -46,6 +61,8 @@ export function toolCallSummary(
46
61
  ): { main: string; detail: string } {
47
62
  const title = opts.title ?? humanizeToolLabel(toolName);
48
63
  const variant = opts.variant ?? "default";
64
+ const clip = (value: unknown) =>
65
+ clipSummaryInput(value, opts.fullInput === true);
49
66
  if (!args || typeof args !== "object") return { main: title, detail: "" };
50
67
  const name = toolName.toLowerCase();
51
68
  const value = (fallback: string, ...keys: string[]) => {