pi-umbra 0.2.0 → 0.3.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.
Files changed (60) hide show
  1. package/README.md +7 -5
  2. package/node_modules/pi-umbra-help/README.md +1 -0
  3. package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +7 -0
  4. package/node_modules/pi-umbra-help/extensions/umbra-help.ts +12 -1
  5. package/node_modules/pi-umbra-help/package.json +1 -1
  6. package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +2 -1
  7. package/node_modules/pi-umbra-inputbar/package.json +1 -1
  8. package/node_modules/pi-umbra-shimmer/package.json +1 -1
  9. package/node_modules/pi-umbra-shimmer/patch.mjs +21 -7
  10. package/node_modules/pi-umbra-skill-matcher/package.json +1 -1
  11. package/node_modules/pi-umbra-skill-matcher/patch.mjs +21 -7
  12. package/node_modules/pi-umbra-subagents/LICENSE +21 -0
  13. package/node_modules/pi-umbra-subagents/README.md +101 -0
  14. package/node_modules/pi-umbra-subagents/extensions/umbra-loop.ts +102 -0
  15. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar/bar-line.ts +262 -0
  16. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.check.ts +198 -0
  17. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/bar.ts +229 -0
  18. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/index.ts +141 -0
  19. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/models.ts +137 -0
  20. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/spec.ts +88 -0
  21. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.check.ts +140 -0
  22. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/fan/store.ts +490 -0
  23. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.check.ts +237 -0
  24. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/panel.ts +378 -0
  25. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/SKILL.md +95 -0
  26. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/beacon.ts +210 -0
  27. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/delegate.env +12 -0
  28. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/report.md +15 -0
  29. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/run.check.sh +120 -0
  30. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/run.sh +170 -0
  31. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.check.ts +137 -0
  32. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/delegate/state.ts +295 -0
  33. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents/skills/fan/SKILL.md +52 -0
  34. package/node_modules/pi-umbra-subagents/extensions/umbra-subagents.ts +4 -0
  35. package/node_modules/pi-umbra-subagents/package.json +43 -0
  36. package/node_modules/pi-umbra-subagents/patch.mjs +97 -0
  37. package/node_modules/pi-umbra-theme/README.md +39 -16
  38. package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +44 -1
  39. package/node_modules/pi-umbra-theme/checks/umbra-image-viewer.check.ts +85 -0
  40. package/node_modules/pi-umbra-theme/checks/umbra-toolbox.check.ts +20 -1
  41. package/node_modules/pi-umbra-theme/checks/umbra-working.check.ts +17 -10
  42. package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +52 -1
  43. package/node_modules/pi-umbra-theme/extensions/umbra-image-viewer.ts +230 -0
  44. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/compact-mode.ts +3 -3
  45. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/hover.ts +0 -4
  46. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/interaction.ts +20 -68
  47. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/layout.ts +0 -4
  48. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/mouse/scroll.ts +6 -190
  49. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/diff/diff-palette.ts +44 -3
  50. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/diff/shiki-highlight.ts +5 -3
  51. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox/renderer/tool/names.ts +1 -0
  52. package/node_modules/pi-umbra-theme/extensions/umbra-working.ts +16 -6
  53. package/node_modules/pi-umbra-theme/package.json +1 -1
  54. package/node_modules/pi-umbra-theme/themes/umbra-astral-veil.json +1 -1
  55. package/node_modules/pi-umbra-theme/themes/umbra-deep-current.json +4 -4
  56. package/node_modules/pi-umbra-theme/themes/umbra-ember-ash.json +10 -10
  57. package/node_modules/pi-umbra-theme/themes/umbra-onyx-slate.json +5 -5
  58. package/node_modules/pi-umbra-theme/themes/umbra-tidal-drift.json +20 -20
  59. package/node_modules/pi-umbra-theme/themes/umbra-venom-dusk.json +4 -4
  60. package/package.json +16 -9
@@ -0,0 +1,230 @@
1
+ // umbra-image-viewer: a full-screen viewer for an image under a tool card. The wheel zooms toward
2
+ // the pointer and a drag pans, the way a map does.
3
+ //
4
+ // Kitty only. Its graphics protocol crops (x,y,w,h) and scales (c,r) on the terminal side, so a
5
+ // zoom step never decodes or re-encodes the image. The image goes out once through pi's own kitty
6
+ // cache, keyed by its id; every later frame is a placement with new crop controls.
7
+ //
8
+ // The click that opens it belongs to umbra-toolbox, which already owns pi's fullscreen mouse input
9
+ // and knows which card a row belongs to. The two meet on globalThis.__umbraImageViewer rather than
10
+ // an import, so either one loads, updates or goes away without the other.
11
+ import {
12
+ allocateImageId,
13
+ deleteKittyImage,
14
+ getCapabilities,
15
+ getCellDimensions,
16
+ matchesKey,
17
+ renderImage,
18
+ truncateToWidth,
19
+ } from "@earendil-works/pi-tui";
20
+
21
+ /** Highest zoom, in screen pixels per image pixel. Past this a pixel is a block. */
22
+ const MAX_PIXEL_SCALE = 8;
23
+ const WHEEL_STEP = 1.2;
24
+ const KEY_STEP = 1.5;
25
+
26
+ /** One axis of the view: the source pixels shown, and where on screen they land. */
27
+ export type Axis = { srcStart: number; srcSpan: number; dispStart: number; dispSpan: number; center: number };
28
+
29
+ /** Centred when the image fits the view on this axis, clamped to its edges when it does not. */
30
+ export const axis = (imagePx: number, viewPx: number, scale: number, center: number): Axis => {
31
+ const span = viewPx / scale;
32
+ if (span >= imagePx) {
33
+ const dispSpan = imagePx * scale;
34
+ return { srcStart: 0, srcSpan: imagePx, dispStart: (viewPx - dispSpan) / 2, dispSpan, center: imagePx / 2 };
35
+ }
36
+ const clamped = Math.min(Math.max(center, span / 2), imagePx - span / 2);
37
+ return { srcStart: clamped - span / 2, srcSpan: span, dispStart: 0, dispSpan: viewPx, center: clamped };
38
+ };
39
+
40
+ /** The centre that keeps the image point under `at` in place while the scale goes `from` → `to`. */
41
+ export const zoomCenter = (current: Axis, viewPx: number, at: number, from: number, to: number): number => {
42
+ const point = current.srcStart + (at - current.dispStart) / from;
43
+ return point - (at - viewPx / 2) / to;
44
+ };
45
+
46
+ const imageIdOf = (line: string): number | undefined => {
47
+ const found = /\x1b_G(?:[^;]*,)?i=(\d+)[,;]/.exec(line);
48
+ return found ? Number(found[1]) : undefined;
49
+ };
50
+
51
+ class ImageViewer {
52
+ private zoom = 1;
53
+ private cx: number;
54
+ private cy: number;
55
+ private drag?: { x: number; y: number; cx: number; cy: number };
56
+ private head: string[];
57
+ private tail: string;
58
+
59
+ constructor(
60
+ private readonly tui: any,
61
+ private readonly widthPx: number,
62
+ private readonly heightPx: number,
63
+ sequence: string,
64
+ private readonly close: () => void,
65
+ ) {
66
+ this.cx = widthPx / 2;
67
+ this.cy = heightPx / 2;
68
+ // The transmission renderImage built, minus its size: every frame puts its own crop and
69
+ // cell size in front of the same payload.
70
+ const first = /^\x1b_G([^;]*);/.exec(sequence)!;
71
+ this.head = first[1]!.split(",").filter((control) => !/^[cr]=/.test(control));
72
+ this.tail = sequence.slice(first[0].length);
73
+ }
74
+
75
+ private geometry() {
76
+ const cell = getCellDimensions();
77
+ const cols = Math.max(1, Number(this.tui.terminal?.columns) || 80);
78
+ const rows = Math.max(1, (Number(this.tui.terminal?.rows) || 24) - 1);
79
+ const viewW = cols * cell.widthPx;
80
+ const viewH = rows * cell.heightPx;
81
+ // Fit only shrinks. Stretching a screenshot a few percent to fill the window blurs every
82
+ // pixel and shows nothing more; the wheel is there for a closer look.
83
+ const fit = Math.min(viewW / this.widthPx, viewH / this.heightPx, 1);
84
+ const scale = fit * this.zoom;
85
+ return {
86
+ cell,
87
+ cols,
88
+ rows,
89
+ viewW,
90
+ viewH,
91
+ fit,
92
+ scale,
93
+ x: axis(this.widthPx, viewW, scale, this.cx),
94
+ y: axis(this.heightPx, viewH, scale, this.cy),
95
+ };
96
+ }
97
+
98
+ private zoomAt(px: number, py: number, factor: number): void {
99
+ const g = this.geometry();
100
+ const maxZoom = Math.max(1, MAX_PIXEL_SCALE / g.fit);
101
+ this.zoom = Math.min(Math.max(this.zoom * factor, 1), maxZoom);
102
+ const next = g.fit * this.zoom;
103
+ this.cx = zoomCenter(g.x, g.viewW, px, g.scale, next);
104
+ this.cy = zoomCenter(g.y, g.viewH, py, g.scale, next);
105
+ this.tui.requestRender();
106
+ }
107
+
108
+ private pan(dx: number, dy: number): void {
109
+ const g = this.geometry();
110
+ this.cx = g.x.center + dx / g.scale;
111
+ this.cy = g.y.center + dy / g.scale;
112
+ this.tui.requestRender();
113
+ }
114
+
115
+ render(width: number): string[] {
116
+ const { cell, cols, rows, scale, x, y } = this.geometry();
117
+ this.cx = x.center;
118
+ this.cy = y.center;
119
+ const col = Math.floor(x.dispStart / cell.widthPx);
120
+ const row = Math.floor(y.dispStart / cell.heightPx);
121
+ const srcX = Math.round(x.srcStart);
122
+ const srcY = Math.round(y.srcStart);
123
+ const controls = [
124
+ `x=${srcX}`,
125
+ `y=${srcY}`,
126
+ `w=${Math.max(1, Math.min(this.widthPx - srcX, Math.round(x.srcSpan)))}`,
127
+ `h=${Math.max(1, Math.min(this.heightPx - srcY, Math.round(y.srcSpan)))}`,
128
+ `c=${Math.max(1, Math.min(cols - col, Math.round(x.dispSpan / cell.widthPx)))}`,
129
+ `r=${Math.max(1, Math.min(rows - row, Math.round(y.dispSpan / cell.heightPx)))}`,
130
+ `X=${Math.floor(x.dispStart - col * cell.widthPx)}`,
131
+ `Y=${Math.floor(y.dispStart - row * cell.heightPx)}`,
132
+ ];
133
+ const lines = Array.from({ length: rows }, () => "");
134
+ lines[row] = " ".repeat(col) + `\x1b_G${[...this.head, ...controls].join(",")};${this.tail}`;
135
+ const status = ` ${Math.round(scale * 100)}% · wheel zoom · drag move · double-click zoom in · 0 fit · esc close`;
136
+ lines.push(`\x1b[2m${truncateToWidth(status, width)}\x1b[22m`);
137
+ return lines;
138
+ }
139
+
140
+ handleMouse(event: any): { handled?: boolean; capture?: boolean } {
141
+ const cell = getCellDimensions();
142
+ const px = event.screenX * cell.widthPx + cell.widthPx / 2;
143
+ const py = event.screenY * cell.heightPx + cell.heightPx / 2;
144
+ if (event.type === "wheel") {
145
+ // Only the sign: a touchpad sends many small steps, a wheel a few large ones.
146
+ if (event.wheelDelta) this.zoomAt(px, py, event.wheelDelta < 0 ? WHEEL_STEP : 1 / WHEEL_STEP);
147
+ return { handled: true };
148
+ }
149
+ if (event.type === "press" && event.button === "left") {
150
+ this.drag = { x: event.screenX, y: event.screenY, cx: this.cx, cy: this.cy };
151
+ return { capture: true };
152
+ }
153
+ if (event.type === "drag" && this.drag) {
154
+ const { scale } = this.geometry();
155
+ this.cx = this.drag.cx - ((event.screenX - this.drag.x) * cell.widthPx) / scale;
156
+ this.cy = this.drag.cy - ((event.screenY - this.drag.y) * cell.heightPx) / scale;
157
+ this.tui.requestRender();
158
+ return { handled: true };
159
+ }
160
+ if (event.type === "release") this.drag = undefined;
161
+ if (event.type === "click" && event.clickCount === 2) this.zoomAt(px, py, 2);
162
+ return { handled: true };
163
+ }
164
+
165
+ handleInput(data: string): void {
166
+ const g = this.geometry();
167
+ if (matchesKey(data, "escape") || data === "q") return this.close();
168
+ if (data === "+" || data === "=") return this.zoomAt(g.viewW / 2, g.viewH / 2, KEY_STEP);
169
+ if (data === "-") return this.zoomAt(g.viewW / 2, g.viewH / 2, 1 / KEY_STEP);
170
+ if (data === "0") {
171
+ this.zoom = 1;
172
+ return this.tui.requestRender();
173
+ }
174
+ const step = 8;
175
+ if (matchesKey(data, "left")) return this.pan(-g.viewW / step, 0);
176
+ if (matchesKey(data, "right")) return this.pan(g.viewW / step, 0);
177
+ if (matchesKey(data, "up")) return this.pan(0, -g.viewH / step);
178
+ if (matchesKey(data, "down")) return this.pan(0, g.viewH / step);
179
+ }
180
+
181
+ invalidate(): void {}
182
+ }
183
+
184
+ /**
185
+ * Opens the viewer when `row` of `tool` (a ToolExecutionComponent rendered at `width`) falls on
186
+ * one of its images. False when it does not, or when the terminal is not kitty.
187
+ */
188
+ export function openToolImageAt(tui: any, tool: any, row: number, width: number): boolean {
189
+ if (getCapabilities().images !== "kitty" || typeof tui?.showOverlay !== "function") return false;
190
+ const images: any[] = tool.imageComponents ?? [];
191
+ if (images.length === 0) return false;
192
+ const lines: string[] = tool.render(width);
193
+ for (const image of images) {
194
+ const id = image.getImageId?.();
195
+ if (id === undefined) continue;
196
+ const start = lines.findIndex((line) => imageIdOf(line) === id);
197
+ if (start === -1 || row < start || row >= start + image.render(width).length) continue;
198
+ open(tui, image.base64Data, image.dimensions);
199
+ return true;
200
+ }
201
+ return false;
202
+ }
203
+
204
+ function open(tui: any, data: string, size: { widthPx: number; heightPx: number }): void {
205
+ const id = allocateImageId();
206
+ // Registers the id with pi's kitty cache, so only the first frame carries the payload.
207
+ const sequence = renderImage(data, size, { maxWidthCells: 1, maxHeightCells: 1, imageId: id, moveCursor: false })!.sequence;
208
+ // pi hands extensions a Proxy of the TUI that forwards `set` but not `delete`, so the override
209
+ // is undone by assigning the old method back, never by deleting it.
210
+ const ownComposite = tui.compositeOverlays;
211
+ const close = () => {
212
+ handle.hide();
213
+ tui.compositeOverlays = ownComposite;
214
+ tui.terminal?.write?.(deleteKittyImage(id));
215
+ tui.requestRender();
216
+ };
217
+ const viewer = new ImageViewer(tui, size.widthPx, size.heightPx, sequence, close);
218
+ // pi keeps a base line that holds an image instead of drawing an overlay over it, and the
219
+ // viewer covers the whole screen. So the overlays are laid out over a blank screen, which
220
+ // keeps their bounds and mouse routing right, and the frame is the viewer's own lines.
221
+ tui.compositeOverlays = function (this: any, screen: string[], width: number, height: number) {
222
+ ownComposite.call(this, screen.map(() => ""), width, height);
223
+ return viewer.render(width);
224
+ };
225
+ const handle = tui.showOverlay(viewer, { width: "100%", maxHeight: "100%", anchor: "top-left" });
226
+ }
227
+
228
+ export default function () {
229
+ (globalThis as { __umbraImageViewer?: unknown }).__umbraImageViewer = { openToolImageAt };
230
+ }
@@ -9,7 +9,7 @@
9
9
  * 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
10
10
  *
11
11
  * 工具计数:read 按非空路径去重、其余按调用计数(首次出现顺序);edit/write 不进摘要。
12
- * 时长 = 回合流逝挂钟;进行中 Running...,结束 Ran for。
12
+ * 时长 = 回合流逝挂钟;进行中 Running…,结束 Ran for。
13
13
  * abort/error/length 状态行挂在摘要外层,避免被折叠吞掉。
14
14
  * 最终 agent 回合摘要由 feature/agent-summary 独占(bash/read/edit/write/other)。
15
15
  *
@@ -209,7 +209,7 @@ function buildMessagesSummary(
209
209
 
210
210
  runningActive = runningActiveOverride ?? runningActive;
211
211
  if (runningActive) {
212
- parts.push(durationMs > 0 ? `Running... · ${formatThoughtDuration(durationMs)}` : "Running...");
212
+ parts.push(durationMs > 0 ? `Running… · ${formatThoughtDuration(durationMs)}` : "Running…");
213
213
  } else if (durationMs > 0) parts.push(`Ran for ${formatThoughtDuration(durationMs)}`);
214
214
  for (const [name, count] of counts) parts.push(`${name}×${count}`);
215
215
  return parts.join(", ");
@@ -823,7 +823,7 @@ function compactAssistantLineComponent(
823
823
  const hintText = hint ? ` • ${showMoreHintText()}` : "";
824
824
  const summaryWidth = Math.max(0, available - visibleWidth(hintText));
825
825
  const resolved = typeof summary === "function" ? summary() : summary;
826
- const runningActive = resolved.startsWith("Running...");
826
+ const runningActive = resolved.startsWith("Running…");
827
827
  const hover = hoveredAssistantComponent === component;
828
828
  const frame = runningActive ? (query?.getThinkingAnimationFrame?.() ?? 0) : 0;
829
829
  const leadingBlank = options.leadingBlank !== false;
@@ -9,7 +9,6 @@ import {
9
9
  } from "../tool/result.ts";
10
10
  import type { ToolGroupComponent } from "../tool/grouping.ts";
11
11
  import { componentAtLocalRow, type ComponentRowHit } from "./layout.ts";
12
- import { setScrollButtonHovered } from "./scroll.ts";
13
12
 
14
13
  type SharedToolHoverState = { toolCallId: string | null };
15
14
 
@@ -112,7 +111,6 @@ export function cachedFullscreenComponentAtRow(
112
111
 
113
112
  /** fullscreen 鼠标悬停目标。 */
114
113
  export type FullscreenHoverTarget =
115
- | { kind: "button" }
116
114
  | { kind: "group"; component: ToolGroupComponent }
117
115
  | { kind: "thinking"; component: ThinkingPreviewBlock }
118
116
  | { kind: "message"; component: any }
@@ -146,7 +144,5 @@ export function applyFullscreenHover(tui: any, target: FullscreenHoverTarget | n
146
144
  const nextView = target?.kind === "tool" ? target.view : null;
147
145
  const nextSection = target?.kind === "tool" ? target.section : null;
148
146
  if (setHoveredToolIo(nextView, nextSection)) changed = true;
149
- const nextButton = target?.kind === "button";
150
- if (setScrollButtonHovered(nextButton)) changed = true;
151
147
  if (changed) tui.requestRender?.();
152
148
  }
@@ -48,22 +48,10 @@ import {
48
48
  } from "./layout.ts";
49
49
  import {
50
50
  fullscreenLazyTui,
51
- hideScrollButton,
52
51
  restoreOfficialScrollToEnd,
53
- syncOfficialScrollToEnd,
54
- isScrollBottomInput,
55
- renderScrollButton,
56
- resetScrollButtonState,
57
- scheduleScrollButtonSync,
58
- setScrollButtonHovered,
59
- setScrollButtonVisible,
60
- setScrollButtonWidget,
61
52
  setToolMouseTui,
62
- getScrollButtonVisible,
63
- getScrollButtonWidget,
64
53
  getToolMouseTui,
65
54
  toolMouseInteractionActive,
66
- updateScrollButtonFromInput,
67
55
  } from "./scroll.ts";
68
56
  import {
69
57
  applyFullscreenHover,
@@ -84,7 +72,7 @@ type FrameToolRender = {
84
72
  };
85
73
 
86
74
  type InteractionRegion = {
87
- kind: "collapsed-hint" | "collapse-hint" | "expanded-card" | "show-more" | "scroll-bottom";
75
+ kind: "collapsed-hint" | "collapse-hint" | "expanded-card" | "show-more";
88
76
  row: number;
89
77
  startCol: number;
90
78
  endCol: number;
@@ -125,7 +113,6 @@ function interactionRegionAt(packet: SgrMousePacket): InteractionRegion | null {
125
113
  );
126
114
  return (
127
115
  matches.find((region) => region.kind === "show-more") ??
128
- matches.find((region) => region.kind === "scroll-bottom") ??
129
116
  matches.find((region) => region.kind === "collapsed-hint") ??
130
117
  matches.find((region) => region.kind === "collapse-hint") ??
131
118
  matches.find((region) => region.kind === "expanded-card") ??
@@ -151,8 +138,6 @@ function tryOpenToolIoShowMore(region: InteractionRegion): boolean {
151
138
  function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
152
139
  if (!isSgrIdleMotion(packet)) return;
153
140
  const region = interactionRegionAt(packet);
154
- const nextScrollButtonHovered = region?.kind === "scroll-bottom";
155
- const scrollButtonChanged = setScrollButtonHovered(nextScrollButtonHovered);
156
141
  const component = region?.component;
157
142
  const nextToolCallId = region?.kind === "collapsed-hint" ? (component?.toolCallId ?? null) : null;
158
143
  // group 只在提示文字上高亮(展开卡整行都是 expanded-card,不能算 hint)。
@@ -166,7 +151,6 @@ function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
166
151
  const changed = nextToolCallId !== sharedToolHoverState().toolCallId;
167
152
  setHoveredToolCallId(nextToolCallId);
168
153
  if (
169
- scrollButtonChanged ||
170
154
  setHoveredToolIo(nextIoView, nextIoSection) ||
171
155
  setHoveredToolGroup(nextGroup) ||
172
156
  changed
@@ -265,7 +249,6 @@ function resolveCollapsePress(
265
249
  function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
266
250
  const region = interactionRegionAt(packet);
267
251
  if (!region) return false;
268
- if (region.kind === "scroll-bottom") return false;
269
252
  if (region.kind === "show-more") return tryOpenToolIoShowMore(region);
270
253
  const component = region.component;
271
254
  if (!component) return false;
@@ -399,10 +382,11 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
399
382
  // 否则 skipOfficialCards 会把内部工具当成官方卡跳过。
400
383
  const card = target.group ?? target.owner ?? component;
401
384
  const pressComponent = target.owner ?? component;
402
- // 回到底部按钮:按组件引用命中,不依赖渲染行缓存。
403
- if (getScrollButtonVisible() && component === getScrollButtonWidget()) {
404
- tui.scrollToBottom?.();
405
- hideScrollButton(tui);
385
+ // An image under a tool card opens full screen, collapsed card or not, when umbra-image-viewer
386
+ // is loaded.
387
+ const viewer = (globalThis as any).__umbraImageViewer;
388
+ if (isToolExecutionComponent(component) && viewer?.openToolImageAt(tui, component, target.row, contentWidth)) {
389
+ clearPendingCollapsePress();
406
390
  return true;
407
391
  }
408
392
  const line = hit.box.lines?.[hit.localRow];
@@ -494,14 +478,7 @@ function handleFullscreenToolHover(tui: any, packet: SgrMousePacket): void {
494
478
  const hit = fullscreenLeafAt(layout, x, y);
495
479
  if (hit) {
496
480
  const line = hit.box.lines?.[hit.localRow];
497
- // 回到底部按钮:渲染行文本 + 列区间识别(零组件树开销)。
498
- if (typeof line === "string" && getScrollButtonVisible() && line.includes("[ ↓")) {
499
- const plain = stripTerminalSequencesPreservingLayout(line);
500
- const idx = plain.indexOf("[ ↓");
501
- if (idx >= 0 && x >= idx && x <= idx + plain.length - 1) {
502
- target = { kind: "button" };
503
- }
504
- } else if (typeof line === "string" && !/\x1b]8;/.test(line)) {
481
+ if (typeof line === "string" && !/\x1b]8;/.test(line)) {
505
482
  const width = Math.max(1, Number(tui.terminal?.columns) || 80);
506
483
  const contentWidth = fullscreenContentWidth(hit.box, width);
507
484
  // 与点击共用同一定位算法,避免 hover 自建行段与真实组件树错位。
@@ -570,9 +547,6 @@ function patchFullscreenViewportInput(tui: any): void {
570
547
  tui[FULLSCREEN_VIEWPORT_PATCH] = true;
571
548
  tui.handleViewportInput = function (this: any, data: string) {
572
549
  if (toolMouseInteractionActive() && tui.mode === "fullscreen") {
573
- // 滚动输入(wheel/pageUp/end 等)后同步回到底部按钮显隐;
574
- // 官方 viewport 会消费键盘,扩展监听器无法补偿,必须在这里调度。
575
- scheduleScrollButtonSync(tui, data);
576
550
  const packets = parseSgrMousePackets(data);
577
551
  // 官方 fullscreen 会消费全部鼠标;文本预览 overlay 活动时放行给 focused
578
552
  // custom component,使 [esc] 点击和滚轮可用。
@@ -919,24 +893,12 @@ function handleToolMouseInput(data: string): { consume: true } | undefined {
919
893
  if (!getToolMouseTui()) return undefined;
920
894
  // 惰性 Proxy fullscreen:鼠标由 handleViewportInput 包装消费(官方链之前),
921
895
  // 此处只处理键盘(鼠标事件在官方 listener 已被 consume,到不了这里)。
922
- if (fullscreenLazyTui(getToolMouseTui())) {
923
- scheduleScrollButtonSync(getToolMouseTui(), data);
924
- if (isScrollBottomInput(data)) {
925
- getToolMouseTui().scrollToBottom?.();
926
- hideScrollButton(getToolMouseTui());
927
- return { consume: true };
928
- }
929
- return undefined;
930
- }
931
- updateScrollButtonFromInput(getToolMouseTui(), data);
896
+ if (fullscreenLazyTui(getToolMouseTui())) return undefined;
932
897
  // Off mode restores native input: wheel keeps scrolling through Pi's normal
933
898
  // dispatcher, while hover/click affordances are entirely inactive.
934
899
  if (!toolMouseInteractionActive()) return undefined;
935
900
  const packets = parseSgrMousePackets(data);
936
- if (!packets) {
937
- scheduleScrollButtonSync(getToolMouseTui(), data);
938
- return undefined;
939
- }
901
+ if (!packets) return undefined;
940
902
 
941
903
  let consumed = false;
942
904
  for (const packet of packets) {
@@ -957,7 +919,6 @@ function handleToolMouseInput(data: string): { consume: true } | undefined {
957
919
 
958
920
  // Let scrolling, motion, release, and clicks outside tool results reach the
959
921
  // normal TUI input chain (including other extensions such as pi-zentui).
960
- scheduleScrollButtonSync(getToolMouseTui(), data);
961
922
  return consumed ? { consume: true } : undefined;
962
923
  }
963
924
 
@@ -994,23 +955,18 @@ export function teardownToolMouseInteraction(
994
955
  }
995
956
  restoreToolMouseRenderPatch();
996
957
  restoreFullscreenViewportInput(getToolMouseTui());
997
- restoreOfficialScrollToEnd(getToolMouseTui());
998
- resetScrollButtonState();
999
958
  setToolMouseTui(null);
1000
959
  toolMouseUi = null;
1001
960
  patchRegistry.dispose(TOOL_MOUSE_OWNER_KEY, owner);
1002
961
  if (toolMouseInstallationOwner === owner) toolMouseInstallationOwner = null;
1003
962
  }
1004
963
 
1005
- /** off 模式清理:清空 hover 与回到底部按钮状态(跨模块 rebind 统一经由此函数)。 */
964
+ /** off 模式清理:清空 hover 状态(跨模块 rebind 统一经由此函数)。 */
1006
965
  export function resetToolHoverState(): void {
1007
966
  setHoveredToolCallId(null);
1008
967
  setHoveredThinking(null);
1009
968
  setHoveredMessageDisplay(null);
1010
969
  setHoveredCompactAssistant(null);
1011
- setScrollButtonVisible(false);
1012
- setScrollButtonHovered(false);
1013
- restoreOfficialScrollToEnd(getToolMouseTui());
1014
970
  releaseFullscreenToolMouseMotion(getToolMouseTui());
1015
971
  }
1016
972
 
@@ -1087,33 +1043,29 @@ export function installToolMouseInteraction(
1087
1043
  toolMouseUi = ctx.ui;
1088
1044
  // 0.84+ 的 tui 是惰性 Proxy:regular 保留原生 scrollback;fullscreen
1089
1045
  // 由官方 LayoutFrame 命中,并由扩展补齐 hover 所需的 all-motion 上报。
1090
- ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any, theme: any) => {
1046
+ ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any) => {
1091
1047
  setToolMouseTui(tui);
1092
1048
  setToolTuiFullscreen(fullscreenLazyTui(tui));
1049
+ // The widget draws nothing. It is how the toolbox reaches the TUI, and in fullscreen its
1050
+ // render re-applies the input patches after pi swaps the TUI. pi's own jump-to-latest
1051
+ // indicator is left alone; an older toolbox replaced it with a button, so undo that.
1052
+ restoreOfficialScrollToEnd(tui);
1093
1053
  if (isLazyProxyTui(tui)) {
1094
1054
  patchFullscreenViewportInput(tui);
1095
1055
  ensureFullscreenToolMouseMotion(tui);
1096
- syncOfficialScrollToEnd(tui);
1097
- setScrollButtonWidget({
1098
- render: (width: number) => {
1056
+ return {
1057
+ render: () => {
1099
1058
  patchFullscreenViewportInput(tui);
1100
1059
  ensureFullscreenToolMouseMotion(tui);
1101
- syncOfficialScrollToEnd(tui);
1102
- return renderScrollButton(width, theme);
1060
+ return [];
1103
1061
  },
1104
1062
  invalidate() {},
1105
- });
1106
- return getScrollButtonWidget();
1063
+ };
1107
1064
  }
1108
1065
  // Wrap doRender to capture the live frame for tool click/hover mapping.
1109
1066
  patchToolMouseMotionAfterRender(tui);
1110
1067
  if (toolMouseInteractionActive()) tui?.terminal?.write?.(TOOL_MOUSE_MOTION_ENABLE);
1111
- const widget = {
1112
- render: (width: number) => renderScrollButton(width, theme),
1113
- invalidate() {},
1114
- };
1115
- setScrollButtonWidget(widget);
1116
- return widget;
1068
+ return { render: () => [], invalidate() {} };
1117
1069
  });
1118
1070
  toolMouseInputUnsubscribe = ctx.ui.onTerminalInput(handleToolMouseInput);
1119
1071
  }
@@ -4,7 +4,6 @@ import { isCompactAssistantComponent } from "../compact-mode.ts";
4
4
  import { ToolGroupComponent } from "../tool/grouping.ts";
5
5
  import { isMessageDisplayComponent } from "../tool/message-display.ts";
6
6
  import { isToolExecutionComponent, stripTerminalSequencesPreservingLayout } from "./packets.ts";
7
- import { getScrollButtonWidget } from "./scroll.ts";
8
7
 
9
8
  export type ComponentRowHit = {
10
9
  component: any;
@@ -143,9 +142,6 @@ export function componentAtLocalRow(
143
142
  if (isMessageDisplayComponent(component)) {
144
143
  return { component, row: localRow };
145
144
  }
146
- if (component === getScrollButtonWidget()) {
147
- return { component, row: localRow };
148
- }
149
145
  if (!Array.isArray(component.children)) return null;
150
146
  let offset = 0;
151
147
  for (const child of component.children) {