pi-one-ui 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] - 2026-10-02
10
+
11
+ ### Changed
12
+
13
+ - Restyle Pi's built-in fullscreen end-of-transcript indicator with the pi-one-ui button look: `[ ↓ Back to bottom · <shortcut> ]` in accent color, switching to the text color while hovered. The shortcut text follows the actual `tui.altScreen.bottom` binding. Display conditions, centering, click-to-scroll, and the keyboard shortcut stay on Pi's native mechanism.
14
+
15
+ ### Removed
16
+
17
+ - Remove `renderer.scrollStepLines`. Wheel stepping is controlled by Pi's own `fullscreenWheelScrollLines` setting; the removed wiring assigned a `tui.wheelScrollLines` property that Pi's TUI does not expose, so the configured value never reached the wheel accelerator. A `scrollStepLines` key left in an existing config file is ignored and preserved on save.
18
+ - Remove the fullscreen "Back to bottom" button and its `Ctrl+End` binding. The button duplicated Pi's built-in end-of-transcript indicator (present since Pi 0.99); use the official `End` shortcut and indicator instead.
19
+
20
+ ### Migration
21
+
22
+ - Wheel stepping is now controlled by Pi's own `fullscreenWheelScrollLines` setting in `~/.pi/agent/settings.json` (an integer such as `3`, or `"auto"`). A leftover `renderer.scrollStepLines` key in the pi-one-ui config is ignored and preserved on save.
23
+ - The fullscreen "Back to bottom" button and its `Ctrl+End` binding are gone. Use the official `End` shortcut and the restyled end-of-transcript indicator instead.
24
+ - For an unpinned npm installation, run `pi update npm:pi-one-ui`; for an existing version pin, run `pi install npm:pi-one-ui@0.9.0`. Fully exit and restart Pi after updating the package.
25
+
9
26
  ## [0.8.0] - 2026-09-30
10
27
 
11
28
  ### Changed
@@ -26,6 +43,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
26
43
  - After updating the package, fully exit and restart Pi instead of running `/reload`: the cross-reload patch coordination keys were renamed, so an in-place reload would leave the previous instance's marks unrecognized until the process restarts.
27
44
  - For an unpinned npm installation, run `pi update npm:pi-one-ui`; for an existing version pin, run `pi install npm:pi-one-ui@0.8.0`.
28
45
 
46
+ ### Release verification
47
+
48
+ - Release source: [`e178ac3`](https://github.com/kerolt/pi-one-ui/commit/e178ac3cdef113ea3991996ea711e5be859aef2b). [CI](https://github.com/kerolt/pi-one-ui/actions/runs/36699814167) and [Publish](https://github.com/kerolt/pi-one-ui/actions/runs/36699814519) completed successfully; local and CI `npm run verify` passed 1,190 tests across 70 files, and `npm run pack:check` confirmed 124 package files.
49
+ - npm `latest` resolves to `0.8.0`, published `2026-09-30T10:04:37.491Z` with `gitHead` `e178ac3cdef113ea3991996ea711e5be859aef2b`, package SHA-1 `cf0b057b31feb86fd5e6b8a9de968977b660b526`, integrity `sha512-q39+leDnJf2iSF09OUDbPrRVfrLW1Q+olLIRuhB0uU/6UISnKzHXUyVgzC6mFd2D8spzzDQSPS4LtkxnSoMttw==`, and a published provenance statement (Sigstore log index [3016341299](https://search.sigstore.dev/?logIndex=3016341299)).
50
+ - Real Pi regular/fullscreen TUI probes were not run for this release, so terminal, theme, reload, and third-party Editor combinations still require manual verification.
51
+
29
52
  ## [0.7.2] - 2026-09-28
30
53
 
31
54
  ### Changed
@@ -200,7 +223,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
200
223
 
201
224
  - Reduced rendering lag when expanding settled tool groups by reusing cached child output.
202
225
 
203
- [Unreleased]: https://github.com/kerolt/pi-one-ui/compare/v0.8.0...HEAD
226
+ [Unreleased]: https://github.com/kerolt/pi-one-ui/compare/v0.9.0...HEAD
227
+ [0.9.0]: https://github.com/kerolt/pi-one-ui/compare/v0.8.0...v0.9.0
204
228
  [0.8.0]: https://github.com/kerolt/pi-one-ui/compare/v0.7.2...v0.8.0
205
229
  [0.7.2]: https://github.com/kerolt/pi-one-ui/compare/v0.7.1...v0.7.2
206
230
  [0.7.1]: https://github.com/kerolt/pi-one-ui/compare/v0.7.0...v0.7.1
package/README.en.md CHANGED
@@ -76,9 +76,9 @@ Then open the unified settings panel:
76
76
  /oneui
77
77
  ```
78
78
 
79
- ### Upgrading to 0.8.0
79
+ ### Upgrading to 0.9.0
80
80
 
81
- Pi `>= 0.99.0` is now required. The Editor model label, cwd label, and static border read a theme's `editorModel` / `cwd` / `editorBorder` keys through the palette shape the current Pi exposes; `colors.*` accepts Pi official theme tokens and explicit terminal colors only, so bare terminal color names such as `purple` must be written as `fg:purple`; and the legacy `zentui` values in `selectorBorders.style`, extension status `colorModes`, and the turn summary entry type use new names, with legacy values still read for compatibility.
81
+ This release removes the `renderer.scrollStepLines` setting: wheel stepping is now controlled by Pi's own `fullscreenWheelScrollLines` setting in `~/.pi/agent/settings.json` (an integer such as `3`, or `"auto"`), and a leftover `scrollStepLines` key in the pi-one-ui config is ignored and preserved on save. The fullscreen "Back to bottom" button and its `Ctrl+End` binding are removed; use the official `End` shortcut and indicator instead. The indicator keeps the pi-one-ui button look (accent color, hover highlight).
82
82
 
83
83
  For an unpinned npm installation, run:
84
84
 
@@ -86,7 +86,7 @@ For an unpinned npm installation, run:
86
86
  pi update npm:pi-one-ui
87
87
  ```
88
88
 
89
- If your installation pins a version, run `pi install npm:pi-one-ui@0.8.0` to update the pin. Fully exit and restart Pi after updating the package instead of running `/reload`: the cross-reload patch coordination keys were renamed, so an in-place reload would leave the previous instance's marks unrecognized.
89
+ If your installation pins a version, run `pi install npm:pi-one-ui@0.9.0` to update the pin. Fully exit and restart Pi after updating the package.
90
90
 
91
91
  ## Configuration
92
92
 
package/README.md CHANGED
@@ -76,9 +76,9 @@ pi install git:github.com/kerolt/pi-one-ui
76
76
  /oneui
77
77
  ```
78
78
 
79
- ### 升级到 0.8.0
79
+ ### 升级到 0.9.0
80
80
 
81
- 本次要求 Pi `>= 0.99.0`。Editor 的模型名、目录标签与 static 外框改为从当前 Pi 的调色板结构读取主题里的 `editorModel` / `cwd` / `editorBorder` 键;`colors.*` 收敛为 Pi 官方主题 token 与显式终端色两种写法,裸终端色名(如 `purple`)不再接受,需写成 `fg:purple`;`selectorBorders.style`、扩展状态 `colorModes` 与轮次摘要条目类型中的旧 `zentui` 值改为新名称,读取端仍兼容旧值。
81
+ 本次移除了 `renderer.scrollStepLines` 配置项,滚轮步进改由 Pi 官方设置 `fullscreenWheelScrollLines` 控制(`~/.pi/agent/settings.json` 中写入整数如 `3`,或 `"auto"`),配置文件里遗留的 `scrollStepLines` 键会被忽略并保留。fullscreen 的回到底部按钮与 `Ctrl+End` 绑定一并移除,请使用官方 `End` 快捷键与指示条;指示条外观已替换为 pi-one-ui 样式(accent 色、悬停变色)。
82
82
 
83
83
  未固定版本的 npm 安装可执行:
84
84
 
@@ -86,7 +86,7 @@ pi install git:github.com/kerolt/pi-one-ui
86
86
  pi update npm:pi-one-ui
87
87
  ```
88
88
 
89
- 若安装时固定了版本,执行 `pi install npm:pi-one-ui@0.8.0` 更新版本指定。更新安装包后,完整退出并重启 Pi,不要使用 `/reload`:跨 reload 的补丁协调键已改名,直接重载会让旧实例留下的标记无法被识别。
89
+ 若安装时固定了版本,执行 `pi install npm:pi-one-ui@0.9.0` 更新版本指定。更新安装包后,完整退出并重启 Pi。
90
90
 
91
91
  ## 配置
92
92
 
@@ -56,7 +56,6 @@ export type Config = {
56
56
  animationIntervalMs: number;
57
57
  dimThinkingText: boolean;
58
58
  showStartupHeader: boolean;
59
- scrollStepLines: number;
60
59
  enableSessionReference: boolean;
61
60
  enableSubagentAutocomplete: boolean;
62
61
  enableContextCommand: boolean;
@@ -130,8 +129,6 @@ export const THINKING_ANIMATION_INTERVAL_VALUES = [
130
129
  "120",
131
130
  "180",
132
131
  ];
133
- /** fullscreen 滚轮步进行数预设。 */
134
- export const SCROLL_STEP_LINES_VALUES = ["1", "2", "3", "5", "10"];
135
132
  /** Tools commonly toggled in excludeRenderers via the settings panel. */
136
133
  export const EXCLUDE_RENDERER_CANDIDATES = [
137
134
  "bash",
@@ -161,7 +158,6 @@ export const DEFAULT_CONFIG: Config = {
161
158
  animationIntervalMs: 90,
162
159
  dimThinkingText: false,
163
160
  showStartupHeader: true,
164
- scrollStepLines: 3,
165
161
  enableSessionReference: true,
166
162
  enableSubagentAutocomplete: true,
167
163
  enableContextCommand: true,
@@ -287,12 +283,6 @@ export function normalizeConfig(input: unknown): Config {
287
283
  ),
288
284
  dimThinkingText: source.dimThinkingText === true,
289
285
  showStartupHeader: source.showStartupHeader !== false,
290
- scrollStepLines: pickPositiveInt(
291
- source.scrollStepLines,
292
- DEFAULT_CONFIG.scrollStepLines,
293
- 1,
294
- 50,
295
- ),
296
286
  enableSessionReference: source.enableSessionReference !== false,
297
287
  enableSubagentAutocomplete: source.enableSubagentAutocomplete !== false,
298
288
  enableContextCommand: source.enableContextCommand !== false,
@@ -347,7 +337,6 @@ export function formatConfigStatus(source: Config = config): string {
347
337
  `thinkingAnimation=${source.animationIntervalMs}ms`,
348
338
  `thinkingDim=${source.dimThinkingText ? "on" : "off"}`,
349
339
  `startupHeader=${source.showStartupHeader ? "on" : "off"}`,
350
- `scrollStep=${source.scrollStepLines}`,
351
340
  `sessionRef=${source.enableSessionReference ? "on" : "off"}`,
352
341
  `subagentAuto=${source.enableSubagentAutocomplete ? "on" : "off"}`,
353
342
  `context=${source.enableContextCommand ? "on" : "off"}`,
@@ -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,21 +938,19 @@ 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
 
@@ -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,
@@ -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) {
@@ -1,28 +1,13 @@
1
- import {
2
- getKeybindings,
3
- type Keybinding,
4
- matchesKey,
5
- truncateToWidth,
6
- visibleWidth,
7
- } from "@earendil-works/pi-tui";
8
1
  import { config } from "../../../../app/config/renderer.ts";
9
2
  import { isLazyProxyTui } from "../../../../tools/fullscreen-detect.ts";
10
3
  import {
11
4
  patchRegistry,
12
- SCROLL_BUTTON_STATE_SLOT,
13
5
  TOOL_MOUSE_TUI_SLOT,
14
6
  } from "../../../../tools/patch-keys.ts";
15
- import { parseSgrMousePackets } from "./packets.ts";
16
-
17
- const PAGE_UP_INPUT =
18
- /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
19
- const PAGE_DOWN_INPUT =
20
- /^\x1b\[6;9(?::[12])?~$|^\x1b\[57422;9(?::[12])?u$|^\x1b\[1;6B$/;
21
- const SCROLL_BOTTOM_SHORTCUT = "ctrl+end";
22
7
 
23
8
  /**
24
- * 当前安装的 tui 宿主。宿主放本模块(滚动按钮/调度依赖它),
25
- * 由 mouse-interaction 经 setToolMouseTui 维护;跨模块一律经绑定/setter 访问。
9
+ * 当前安装的 tui 宿主。由 mouse-interaction 经 setToolMouseTui 维护;
10
+ * 跨模块一律经绑定/setter 访问。
26
11
  *
27
12
  * 状态镜像到 globalThis(Symbol 槽):jiti 转译下经 re-export 链读取的
28
13
  * 模块级 let 绑定是初始值快照(死绑定,实测恒 null),函数调用才是活引用。
@@ -38,50 +23,6 @@ export function setToolMouseTui(tui: any): void {
38
23
  patchRegistry.install(TOOL_MOUSE_TUI_SLOT, tui);
39
24
  }
40
25
 
41
- // 滚动按钮状态同 toolMouseTui:镜像到 globalThis(Symbol 槽),跨模块读取
42
- // 一律用 getter(jiti 转译下模块级 let 绑定是初始值快照)。
43
- type ScrollButtonState = { visible: boolean; hovered: boolean; widget: any };
44
- function scrollButtonState(): ScrollButtonState {
45
- return patchRegistry.ensure(SCROLL_BUTTON_STATE_SLOT, () => ({
46
- visible: false,
47
- hovered: false,
48
- widget: null,
49
- }));
50
- }
51
- export function getScrollButtonVisible(): boolean {
52
- return scrollButtonState().visible;
53
- }
54
- export function getScrollButtonHovered(): boolean {
55
- return scrollButtonState().hovered;
56
- }
57
- export function getScrollButtonWidget(): any {
58
- return scrollButtonState().widget;
59
- }
60
- export function setScrollButtonVisible(visible: boolean): void {
61
- scrollButtonState().visible = visible;
62
- }
63
-
64
- /** 返回是否发生变化(调用方据此决定是否需要重渲染)。 */
65
- export function setScrollButtonHovered(hovered: boolean): boolean {
66
- if (hovered === scrollButtonState().hovered) return false;
67
- scrollButtonState().hovered = hovered;
68
- return true;
69
- }
70
-
71
- export function setScrollButtonWidget(widget: any): void {
72
- scrollButtonState().widget = widget;
73
- }
74
-
75
- /** teardown 全量清零(visible/hovered/widget/sync 调度)。 */
76
- export function resetScrollButtonState(): void {
77
- scrollButtonState().visible = false;
78
- scrollButtonState().hovered = false;
79
- scrollButtonState().widget = null;
80
- scrollButtonSyncScheduled = false;
81
- }
82
-
83
- let scrollButtonSyncScheduled = false;
84
-
85
26
  // 交互开关只取决于配置模式:原实现按 isLazyProxyTui(toolMouseTui) 分两分支,
86
27
  // 两分支恒真(0.84+ 惰性 Proxy 下判定不再影响开关),折叠为单条件。
87
28
  export function toolMouseInteractionActive(): boolean {
@@ -92,125 +33,3 @@ export function toolMouseInteractionActive(): boolean {
92
33
  export function fullscreenLazyTui(tui: any): boolean {
93
34
  return isLazyProxyTui(tui) && tui.mode === "fullscreen";
94
35
  }
95
-
96
- /**
97
- * Reports whether fullscreen output is following the bottom of the context.
98
- */
99
- export function isFullscreenAtBottom(tui: any): boolean {
100
- const following =
101
- tui.isFollowingOutput ??
102
- tui.getPrimaryScrollView?.()?.isFollowingEnd ??
103
- true;
104
- return Boolean(following);
105
- }
106
-
107
- function formatShortcut(shortcut: string): string {
108
- return shortcut
109
- .split("+")
110
- .map((part) =>
111
- part.length <= 1
112
- ? part.toUpperCase()
113
- : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`,
114
- )
115
- .join("+");
116
- }
117
-
118
- export function isScrollBottomInput(data: string): boolean {
119
- return matchesKey(data, SCROLL_BOTTOM_SHORTCUT);
120
- }
121
-
122
- function isScrollNavigationInput(data: string): boolean {
123
- if (
124
- matchesKey(data, "pageUp") ||
125
- matchesKey(data, "pageDown") ||
126
- PAGE_UP_INPUT.test(data) ||
127
- PAGE_DOWN_INPUT.test(data) ||
128
- // 官方 fullscreen viewport 的可滚动键(half-page/prompt/top/bottom)。
129
- [
130
- "tui.altScreen.pageUp",
131
- "tui.altScreen.pageDown",
132
- "tui.altScreen.halfPageUp",
133
- "tui.altScreen.halfPageDown",
134
- "tui.altScreen.previousPrompt",
135
- "tui.altScreen.nextPrompt",
136
- "tui.altScreen.top",
137
- "tui.altScreen.bottom",
138
- ].some((key) => getKeybindings().matches(data, key as Keybinding))
139
- ) {
140
- return true;
141
- }
142
- const packets = parseSgrMousePackets(data);
143
- return Boolean(
144
- packets?.some((packet) => {
145
- const baseButton = packet.code & ~(4 | 8 | 16 | 32);
146
- return packet.final === "M" && (baseButton === 64 || baseButton === 65);
147
- }),
148
- );
149
- }
150
-
151
- /**
152
- * Reports whether the context is at the bottom for the detected TUI mode.
153
- */
154
- function isAtContextBottom(tui: any): boolean {
155
- // 惰性 Proxy fullscreen:官方 viewport 以 isFollowingOutput 判定是否在底部。
156
- if (fullscreenLazyTui(tui)) return isFullscreenAtBottom(tui);
157
- return true;
158
- }
159
-
160
- export function hideScrollButton(tui: any): void {
161
- const changed = getScrollButtonVisible() || getScrollButtonHovered();
162
- setScrollButtonVisible(false);
163
- setScrollButtonHovered(false);
164
- if (changed) tui.requestRender?.();
165
- }
166
-
167
- export function scheduleScrollButtonSync(tui: any, data: string): void {
168
- if (
169
- !fullscreenLazyTui(tui) ||
170
- !toolMouseInteractionActive() ||
171
- !isScrollNavigationInput(data) ||
172
- scrollButtonSyncScheduled
173
- )
174
- return;
175
- scrollButtonSyncScheduled = true;
176
- const previousLines = tui.previousLines;
177
- const check = (attempt: number) => {
178
- scrollButtonSyncScheduled = false;
179
- if (toolMouseTui !== tui) return;
180
- // Pi renders on its own frame timer. Inspect the resulting viewport before
181
- // showing the button so empty or non-scrollable contexts never flash it.
182
- const rendered = tui.previousLines !== previousLines;
183
- // fullscreen 下 isFollowingOutput 是即时状态,无需等待官方帧渲染。
184
- if (!rendered && attempt < 4 && !fullscreenLazyTui(tui)) {
185
- scrollButtonSyncScheduled = true;
186
- const timer = setTimeout(() => check(attempt + 1), 16);
187
- if (typeof timer === "object" && timer !== null && "unref" in timer) {
188
- (timer as { unref: () => void }).unref();
189
- }
190
- return;
191
- }
192
- const nextVisible = !isAtContextBottom(tui);
193
- if (nextVisible !== getScrollButtonVisible()) {
194
- setScrollButtonVisible(nextVisible);
195
- tui.requestRender?.();
196
- }
197
- };
198
- process.nextTick(() => check(0));
199
- }
200
-
201
- export function updateScrollButtonFromInput(tui: any, data: string): void {
202
- if (!fullscreenLazyTui(tui) || !toolMouseInteractionActive()) return;
203
- if (matchesKey(data, "enter") || matchesKey(data, "return"))
204
- hideScrollButton(tui);
205
- }
206
-
207
- export function renderScrollButton(width: number, theme: any): string[] {
208
- if (!getScrollButtonVisible() || !fullscreenLazyTui(toolMouseTui)) return [];
209
- const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
210
- const label = theme.fg(
211
- getScrollButtonHovered() ? "text" : "accent",
212
- `[ ↓ Back to bottom · ${shortcut} ]`,
213
- );
214
- const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
215
- return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
216
- }
@@ -81,9 +81,6 @@ export const TOOL_GROUPING_GENERATION_KEY = Symbol.for(
81
81
  // ── 鼠标交互 ──
82
82
  export const TOOL_MOUSE_OWNER_KEY = Symbol.for("pi-one-ui.tool-mouse-owner");
83
83
  export const TOOL_MOUSE_TUI_SLOT = Symbol.for("pi-one-ui.tool-mouse-tui");
84
- export const SCROLL_BUTTON_STATE_SLOT = Symbol.for(
85
- "pi-one-ui.scroll-button-state",
86
- );
87
84
  export const FLUSH_DOCKED_BASH_PATCH = Symbol.for(
88
85
  "pi-one-ui.flush-docked-bash-patch",
89
86
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-one-ui",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "A single, customizable Pi UI package with layout-oriented Context and Shell rendering for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",