pi-one-ui 0.1.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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/extensions/app/config/renderer.ts +452 -0
  4. package/extensions/app/config/shell.ts +2484 -0
  5. package/extensions/app/ownership.ts +18 -0
  6. package/extensions/app/panel.ts +533 -0
  7. package/extensions/app/presets.ts +36 -0
  8. package/extensions/features/agent-summary/core.ts +194 -0
  9. package/extensions/features/agent-summary/index.ts +37 -0
  10. package/extensions/features/compact-thinking.ts +1262 -0
  11. package/extensions/features/context.ts +857 -0
  12. package/extensions/features/reference/index.ts +643 -0
  13. package/extensions/features/reference/session.ts +278 -0
  14. package/extensions/features/reference/subagent.ts +218 -0
  15. package/extensions/features/shell/aliases.ts +28 -0
  16. package/extensions/features/shell/flush-docked-bash.ts +39 -0
  17. package/extensions/features/shell/startup-header.ts +357 -0
  18. package/extensions/features/shell/working-message.ts +226 -0
  19. package/extensions/index.ts +52 -0
  20. package/extensions/shell/accent-rail-editor.ts +112 -0
  21. package/extensions/shell/accent-rail-layout-patch.ts +530 -0
  22. package/extensions/shell/completion-menu.ts +186 -0
  23. package/extensions/shell/editor-metadata-format.ts +287 -0
  24. package/extensions/shell/editor-transfer.ts +73 -0
  25. package/extensions/shell/extension-status.ts +102 -0
  26. package/extensions/shell/footer-format.ts +388 -0
  27. package/extensions/shell/footer-layout.ts +144 -0
  28. package/extensions/shell/footer.ts +1035 -0
  29. package/extensions/shell/format.ts +577 -0
  30. package/extensions/shell/git.ts +434 -0
  31. package/extensions/shell/icons.ts +261 -0
  32. package/extensions/shell/index.ts +1659 -0
  33. package/extensions/shell/interaction-summary.ts +1194 -0
  34. package/extensions/shell/live-context.ts +82 -0
  35. package/extensions/shell/minimalist-editor.ts +548 -0
  36. package/extensions/shell/package-version.ts +675 -0
  37. package/extensions/shell/project-refresh.ts +130 -0
  38. package/extensions/shell/project-state.ts +60 -0
  39. package/extensions/shell/prototype-patch-registry.ts +183 -0
  40. package/extensions/shell/runtime.ts +915 -0
  41. package/extensions/shell/selector-border.ts +109 -0
  42. package/extensions/shell/session-lifecycle.ts +60 -0
  43. package/extensions/shell/settings-command.ts +2144 -0
  44. package/extensions/shell/settings-previews.ts +137 -0
  45. package/extensions/shell/state.ts +83 -0
  46. package/extensions/shell/style.ts +480 -0
  47. package/extensions/shell/telemetry.ts +90 -0
  48. package/extensions/shell/ui.ts +1326 -0
  49. package/extensions/shell/user-message-osc.ts +279 -0
  50. package/extensions/shell/user-message-styles.ts +266 -0
  51. package/extensions/shell/user-message.ts +226 -0
  52. package/extensions/shell/working-line-messages.ts +24 -0
  53. package/extensions/shell/working-line-spinners.ts +54 -0
  54. package/extensions/shell/working-line.ts +1603 -0
  55. package/extensions/tools/ansi-color.ts +44 -0
  56. package/extensions/tools/ansi-text.ts +52 -0
  57. package/extensions/tools/component-tree.ts +37 -0
  58. package/extensions/tools/format.ts +28 -0
  59. package/extensions/tools/fullscreen-detect.ts +11 -0
  60. package/extensions/tools/patch-keys.ts +158 -0
  61. package/extensions/tools/sgr-mouse.ts +59 -0
  62. package/extensions/tools/tool-loading-icon.ts +20 -0
  63. package/extensions/tools/tool-result-sanitize.ts +27 -0
  64. package/extensions/transcript/index.ts +65 -0
  65. package/extensions/transcript/renderer/compact-mode.ts +1443 -0
  66. package/extensions/transcript/renderer/default-mode.ts +735 -0
  67. package/extensions/transcript/renderer/index.ts +286 -0
  68. package/extensions/transcript/renderer/markdown-enhance.ts +325 -0
  69. package/extensions/transcript/renderer/mouse/hover.ts +175 -0
  70. package/extensions/transcript/renderer/mouse/interaction.ts +1016 -0
  71. package/extensions/transcript/renderer/mouse/layout.ts +207 -0
  72. package/extensions/transcript/renderer/mouse/packets.ts +96 -0
  73. package/extensions/transcript/renderer/mouse/scroll.ts +211 -0
  74. package/extensions/transcript/renderer/tool/diff/ATTRIBUTION.md +28 -0
  75. package/extensions/transcript/renderer/tool/diff/ansi-utils.ts +128 -0
  76. package/extensions/transcript/renderer/tool/diff/diff-component.ts +112 -0
  77. package/extensions/transcript/renderer/tool/diff/diff-edit-render.ts +255 -0
  78. package/extensions/transcript/renderer/tool/diff/diff-header.ts +184 -0
  79. package/extensions/transcript/renderer/tool/diff/diff-highlight.ts +131 -0
  80. package/extensions/transcript/renderer/tool/diff/diff-inline.ts +350 -0
  81. package/extensions/transcript/renderer/tool/diff/diff-layout.ts +870 -0
  82. package/extensions/transcript/renderer/tool/diff/diff-limits.ts +189 -0
  83. package/extensions/transcript/renderer/tool/diff/diff-palette.ts +436 -0
  84. package/extensions/transcript/renderer/tool/diff/diff-parse.ts +536 -0
  85. package/extensions/transcript/renderer/tool/diff/diff-presentation.ts +79 -0
  86. package/extensions/transcript/renderer/tool/diff/diff-renderer.ts +29 -0
  87. package/extensions/transcript/renderer/tool/diff/diff-text.ts +57 -0
  88. package/extensions/transcript/renderer/tool/diff/diff-write-render.ts +629 -0
  89. package/extensions/transcript/renderer/tool/diff/index.ts +147 -0
  90. package/extensions/transcript/renderer/tool/diff/line-width-safety.ts +100 -0
  91. package/extensions/transcript/renderer/tool/diff/shiki-highlight.ts +104 -0
  92. package/extensions/transcript/renderer/tool/diff/write-execution.ts +124 -0
  93. package/extensions/transcript/renderer/tool/grouping.ts +619 -0
  94. package/extensions/transcript/renderer/tool/message-display.ts +183 -0
  95. package/extensions/transcript/renderer/tool/names.ts +195 -0
  96. package/extensions/transcript/renderer/tool/result.ts +842 -0
  97. package/extensions/transcript/renderer/tool/show-more-hint.ts +42 -0
  98. package/extensions/transcript/renderer/transcript-refresh.ts +79 -0
  99. package/package.json +58 -0
  100. package/themes/cc-dark.json +91 -0
  101. package/themes/cc-light.json +91 -0
@@ -0,0 +1,207 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { ThinkingPreviewBlock } from "../../../features/compact-thinking.ts";
3
+ import { isCompactAssistantComponent } from "../compact-mode.ts";
4
+ import { ToolGroupComponent } from "../tool/grouping.ts";
5
+ import { isMessageDisplayComponent } from "../tool/message-display.ts";
6
+ import {
7
+ isToolExecutionComponent,
8
+ stripTerminalSequencesPreservingLayout,
9
+ } from "./packets.ts";
10
+ import { getScrollButtonWidget } from "./scroll.ts";
11
+
12
+ export type ComponentRowHit = {
13
+ component: any;
14
+ row: number;
15
+ /** 内部工具命中时所属的展开 group;普通卡点击仍折叠整个 group。 */
16
+ group?: ToolGroupComponent;
17
+ };
18
+
19
+ /** 行内 [click to show more] 提示的命中列区间(1-based,含两端)。 */
20
+ export function collapsedHintHitbox(
21
+ line: string,
22
+ ): { startCol: number; endCol: number } | null {
23
+ const plain = stripTerminalSequencesPreservingLayout(line);
24
+ const match =
25
+ /(\([^()\n]* \/ click\)|click to show more|to show more)(?=\)?\s*$)/.exec(
26
+ plain,
27
+ );
28
+ if (!match?.[1]) return null;
29
+ const startCol = visibleWidth(plain.slice(0, match.index)) + 1;
30
+ return { startCol, endCol: startCol + visibleWidth(match[1]) - 1 };
31
+ }
32
+
33
+ /** 布局树点查询:返回 (x,y) 处最深含行 leaf box(屏幕行 → 组件局部行)。 */
34
+ export function fullscreenLeafAt(
35
+ layout: any,
36
+ x: number,
37
+ y: number,
38
+ ): { box: any; localRow: number } | null {
39
+ const root = layout?.root;
40
+ if (!root) return null;
41
+ let best: { box: any; localRow: number } | null = null;
42
+ let bestDepth = -1;
43
+ const visit = (box: any, depth: number) => {
44
+ if (!box) return;
45
+ const clip = box.clip;
46
+ if (
47
+ !clip ||
48
+ x < clip.x ||
49
+ x >= clip.x + clip.width ||
50
+ y < clip.y ||
51
+ y >= clip.y + clip.height
52
+ )
53
+ return;
54
+ const isLeaf = !Array.isArray(box.children) || box.children.length === 0;
55
+ if (
56
+ isLeaf &&
57
+ y >= box.rect.y &&
58
+ y < box.rect.y + Math.max(1, box.rect.height) &&
59
+ depth > bestDepth
60
+ ) {
61
+ best = { box, localRow: Math.max(0, y - box.rect.y) };
62
+ bestDepth = depth;
63
+ }
64
+ for (const child of box.children ?? []) visit(child, depth + 1);
65
+ };
66
+ visit(root, 0);
67
+ return best;
68
+ }
69
+
70
+ /** leaf 自身不带 scrollView;内容宽度由最近的 scroll 祖先决定。 */
71
+ export function fullscreenContentWidth(
72
+ box: any,
73
+ terminalWidth: number,
74
+ ): number {
75
+ for (let current = box; current; current = current.parent) {
76
+ if (typeof current.scrollView?.getContentWidth === "function") {
77
+ return Math.max(1, current.scrollView.getContentWidth(terminalWidth));
78
+ }
79
+ }
80
+ return terminalWidth;
81
+ }
82
+
83
+ /** 点击列是否为官方滚动条列(放行官方拖动)。 */
84
+ export function isScrollbarColumnAt(layout: any, x: number): boolean {
85
+ let hit = false;
86
+ const visit = (box: any) => {
87
+ if (hit || !box) return;
88
+ if (
89
+ box.scrollView?.isScrollbarVisible &&
90
+ x === box.rect.x + box.rect.width - 1
91
+ ) {
92
+ hit = true;
93
+ return;
94
+ }
95
+ for (const child of box.children ?? []) visit(child);
96
+ };
97
+ visit(layout?.root);
98
+ return hit;
99
+ }
100
+
101
+ /** compact 展开卡用 childAtRow 映射 thinking;其余按 render 行数下钻。 */
102
+ function nestedChildAtRow(node: any, localRow: number, width: number): any {
103
+ if (typeof node?.childAtRow === "function") {
104
+ return node.childAtRow(localRow, width);
105
+ }
106
+ if (!Array.isArray(node?.children)) return null;
107
+ let offset = 0;
108
+ for (const child of node.children) {
109
+ let count = 0;
110
+ try {
111
+ const rendered = child.render?.(width);
112
+ if (Array.isArray(rendered)) count = rendered.length;
113
+ } catch {
114
+ count = 0;
115
+ }
116
+ if (localRow < offset + count) {
117
+ return nestedChildAtRow(child, localRow - offset, width);
118
+ }
119
+ offset += count;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * 布局 leaf box 的组件通常是容器(documentContainer/dock 容器等),工具卡与
126
+ * widget 在其 children 内。按局部行遍历组件树,定位实际命中的子组件。
127
+ */
128
+ export function componentAtLocalRow(
129
+ component: any,
130
+ localRow: number,
131
+ width: number,
132
+ ): ComponentRowHit | null {
133
+ if (component instanceof ToolGroupComponent) {
134
+ // 展开的 group:头两行(空行 + 头行)归 group,其余行映射到内部工具。
135
+ const child = component.childAtRow(localRow, width);
136
+ return child
137
+ ? { ...child, group: component }
138
+ : { component, row: localRow };
139
+ }
140
+ if (isToolExecutionComponent(component)) {
141
+ return { component, row: localRow };
142
+ }
143
+ if (isCompactAssistantComponent(component)) {
144
+ // 折叠:整行摘要。展开:先命中内部 thinking hint,其余仍归外层卡片。
145
+ if (component.expanded === true) {
146
+ const inner = nestedChildAtRow(component, localRow, width);
147
+ if (inner instanceof ThinkingPreviewBlock) {
148
+ return { component: inner, row: localRow };
149
+ }
150
+ }
151
+ return { component, row: localRow };
152
+ }
153
+ if (component instanceof ThinkingPreviewBlock) {
154
+ return { component, row: localRow };
155
+ }
156
+ if (isMessageDisplayComponent(component)) {
157
+ return { component, row: localRow };
158
+ }
159
+ if (component === getScrollButtonWidget()) {
160
+ return { component, row: localRow };
161
+ }
162
+ if (!Array.isArray(component.children)) return null;
163
+ let offset = 0;
164
+ for (const child of component.children) {
165
+ let lines: string[] = [];
166
+ try {
167
+ const rendered = child.render?.(width);
168
+ if (Array.isArray(rendered)) lines = rendered.map((line) => String(line));
169
+ } catch {
170
+ lines = [];
171
+ }
172
+ if (localRow < offset + lines.length) {
173
+ return (
174
+ componentAtLocalRow(child, localRow - offset, width) ?? {
175
+ component: child,
176
+ row: localRow - offset,
177
+ }
178
+ );
179
+ }
180
+ offset += lines.length;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ /** fullscreen single-expand:group 作为整体,不继续递归其内部工具。 */
186
+ export function collectFullscreenToolCards(
187
+ component: any,
188
+ out: any[],
189
+ seen = new Set<any>(),
190
+ ): void {
191
+ if (!component || typeof component !== "object" || seen.has(component))
192
+ return;
193
+ seen.add(component);
194
+ if (
195
+ isToolExecutionComponent(component) ||
196
+ component instanceof ToolGroupComponent ||
197
+ isCompactAssistantComponent(component) ||
198
+ component instanceof ThinkingPreviewBlock ||
199
+ isMessageDisplayComponent(component)
200
+ ) {
201
+ out.push(component);
202
+ return;
203
+ }
204
+ if (!Array.isArray(component.children)) return;
205
+ for (const child of component.children)
206
+ collectFullscreenToolCards(child, out, seen);
207
+ }
@@ -0,0 +1,96 @@
1
+ import type { ExpandedToolIoView, ToolIoSection } from "../tool/result.ts";
2
+ import { walkComponentTree } from "../../../tools/component-tree.ts";
3
+ export {
4
+ stripTerminalSequences,
5
+ stripTerminalSequencesPreservingLayout,
6
+ } from "../../../tools/ansi-text.ts";
7
+ export {
8
+ parseSgrMousePackets,
9
+ isSgrLeftPress,
10
+ type SgrMousePacket,
11
+ } from "../../../tools/sgr-mouse.ts";
12
+
13
+ /** Final painted placement of one outermost tool/group row after parent layout. */
14
+ export type FrameToolPlacement = {
15
+ component: any;
16
+ componentRow: number;
17
+ lineIndex: number;
18
+ /** Marker-stripped final line text as painted after parent layout. */
19
+ finalLine: string;
20
+ view?: ExpandedToolIoView;
21
+ section?: ToolIoSection;
22
+ };
23
+
24
+ /** Zero-width APC row marker (like pi CURSOR_MARKER); stripped before terminal output. */
25
+ const TOOL_FRAME_MARKER_RE = /\x1b_cc:t(\d+):(\d+)\x07/g;
26
+ const TOOL_VIEW_MARKER_RE = /\x1b_cc:v(\d+):([io])\x07/g;
27
+ export const toolFrameMarker = (id: number, row: number) =>
28
+ `\x1b_cc:t${id}:${row}\x07`;
29
+
30
+ /** 工具执行组件识别(toolCallId + setExpanded + render)。 */
31
+ export function isToolExecutionComponent(value: any): boolean {
32
+ return Boolean(
33
+ value &&
34
+ typeof value === "object" &&
35
+ typeof value.toolCallId === "string" &&
36
+ typeof value.setExpanded === "function" &&
37
+ typeof value.render === "function",
38
+ );
39
+ }
40
+
41
+ /** 深度收集组件树中的工具执行组件(含 getMountedRoots 分支)。 */
42
+ export function collectToolComponents(component: any, tools: any[]): void {
43
+ walkComponentTree(component, (value: any) => {
44
+ if (isToolExecutionComponent(value)) {
45
+ tools.push(value);
46
+ return false;
47
+ }
48
+ });
49
+ }
50
+
51
+ /** 剥离一行中的零宽帧/视图 marker。 */
52
+ export function stripToolFrameMarkers(line: string): string {
53
+ return line
54
+ .replace(TOOL_FRAME_MARKER_RE, "")
55
+ .replace(TOOL_VIEW_MARKER_RE, "");
56
+ }
57
+
58
+ /**
59
+ * 从渲染行中提取工具帧 placement(component/componentRow/lineIndex/finalLine
60
+ * 及其所属视图与 section),同时返回剥离 marker 后的行。
61
+ */
62
+ export function extractToolFramePlacements(
63
+ lines: string[],
64
+ idToComponent: Map<number, any>,
65
+ idToView: Map<number, ExpandedToolIoView>,
66
+ ): { lines: string[]; placements: FrameToolPlacement[] } {
67
+ const placements: FrameToolPlacement[] = [];
68
+ const cleaned = lines.map((line, lineIndex) => {
69
+ const toolMatches = [...line.matchAll(TOOL_FRAME_MARKER_RE)];
70
+ const viewMatches = [...line.matchAll(TOOL_VIEW_MARKER_RE)];
71
+ const finalLine = stripToolFrameMarkers(line);
72
+ let view: ExpandedToolIoView | undefined;
73
+ let section: ToolIoSection | undefined;
74
+ for (const match of viewMatches) {
75
+ const candidate = idToView.get(Number(match[1]));
76
+ if (!candidate) continue;
77
+ view = candidate;
78
+ section = match[2] === "i" ? "input" : "output";
79
+ break;
80
+ }
81
+ for (const match of toolMatches) {
82
+ const component = idToComponent.get(Number(match[1]));
83
+ if (!component) continue;
84
+ placements.push({
85
+ component,
86
+ componentRow: Number(match[2]),
87
+ lineIndex,
88
+ finalLine,
89
+ view,
90
+ section,
91
+ });
92
+ }
93
+ return finalLine;
94
+ });
95
+ return { lines: cleaned, placements };
96
+ }
@@ -0,0 +1,211 @@
1
+ import {
2
+ getKeybindings,
3
+ matchesKey,
4
+ truncateToWidth,
5
+ visibleWidth,
6
+ type Keybinding,
7
+ } from "@earendil-works/pi-tui";
8
+ import { config } from "../../../app/config/renderer.ts";
9
+ import { isLazyProxyTui } from "../../../tools/fullscreen-detect.ts";
10
+ import { parseSgrMousePackets } from "./packets.ts";
11
+ import {
12
+ patchRegistry,
13
+ SCROLL_BUTTON_STATE_SLOT,
14
+ TOOL_MOUSE_TUI_SLOT,
15
+ } from "../../../tools/patch-keys.ts";
16
+
17
+ const ZENTUI_PAGE_UP_INPUT =
18
+ /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
19
+ const ZENTUI_PAGE_DOWN_INPUT =
20
+ /^\x1b\[6;9(?::[12])?~$|^\x1b\[57422;9(?::[12])?u$|^\x1b\[1;6B$/;
21
+ const SCROLL_BOTTOM_SHORTCUT = "ctrl+end";
22
+
23
+ /**
24
+ * 当前安装的 tui 宿主。宿主放本模块(滚动按钮/调度依赖它),
25
+ * 由 mouse-interaction 经 setToolMouseTui 维护;跨模块一律经绑定/setter 访问。
26
+ *
27
+ * 状态镜像到 globalThis(Symbol 槽):jiti 转译下经 re-export 链读取的
28
+ * 模块级 let 绑定是初始值快照(死绑定,实测恒 null),函数调用才是活引用。
29
+ * 跨模块读取一律用 getToolMouseTui(),避免拿到加载时的快照。
30
+ */
31
+ patchRegistry.ensure(TOOL_MOUSE_TUI_SLOT, () => null);
32
+ export let toolMouseTui: any = null;
33
+ export function getToolMouseTui(): any {
34
+ return patchRegistry.get(TOOL_MOUSE_TUI_SLOT);
35
+ }
36
+ export function setToolMouseTui(tui: any): void {
37
+ toolMouseTui = tui;
38
+ patchRegistry.install(TOOL_MOUSE_TUI_SLOT, tui);
39
+ }
40
+
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
+ // 交互开关只取决于配置模式:原实现按 isLazyProxyTui(toolMouseTui) 分两分支,
86
+ // 两分支恒真(0.84+ 惰性 Proxy 下判定不再影响开关),折叠为单条件。
87
+ export function toolMouseInteractionActive(): boolean {
88
+ return config.mode !== "off";
89
+ }
90
+
91
+ /** 惰性 Proxy 官方 fullscreen(TuiAltScreen)判定。 */
92
+ export function fullscreenLazyTui(tui: any): boolean {
93
+ return isLazyProxyTui(tui) && tui.mode === "fullscreen";
94
+ }
95
+
96
+ /** 官方 fullscreen:是否已跟随 transcript 底部(按钮隐藏条件)。 */
97
+ export function isFullscreenAtBottom(tui: any): boolean {
98
+ const following =
99
+ tui.isFollowingOutput ??
100
+ tui.getPrimaryScrollView?.()?.isFollowingEnd ??
101
+ true;
102
+ return Boolean(following);
103
+ }
104
+
105
+ function formatShortcut(shortcut: string): string {
106
+ return shortcut
107
+ .split("+")
108
+ .map((part) =>
109
+ part.length <= 1
110
+ ? part.toUpperCase()
111
+ : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`,
112
+ )
113
+ .join("+");
114
+ }
115
+
116
+ export function isScrollBottomInput(data: string): boolean {
117
+ return matchesKey(data, SCROLL_BOTTOM_SHORTCUT);
118
+ }
119
+
120
+ function isScrollNavigationInput(data: string): boolean {
121
+ if (
122
+ matchesKey(data, "pageUp") ||
123
+ matchesKey(data, "pageDown") ||
124
+ ZENTUI_PAGE_UP_INPUT.test(data) ||
125
+ ZENTUI_PAGE_DOWN_INPUT.test(data) ||
126
+ // 官方 fullscreen viewport 的可滚动键(half-page/prompt/top/bottom)。
127
+ [
128
+ "tui.altScreen.pageUp",
129
+ "tui.altScreen.pageDown",
130
+ "tui.altScreen.halfPageUp",
131
+ "tui.altScreen.halfPageDown",
132
+ "tui.altScreen.previousPrompt",
133
+ "tui.altScreen.nextPrompt",
134
+ "tui.altScreen.top",
135
+ "tui.altScreen.bottom",
136
+ ].some((key) => getKeybindings().matches(data, key as Keybinding))
137
+ ) {
138
+ return true;
139
+ }
140
+ const packets = parseSgrMousePackets(data);
141
+ return Boolean(
142
+ packets?.some((packet) => {
143
+ const baseButton = packet.code & ~(4 | 8 | 16 | 32);
144
+ return packet.final === "M" && (baseButton === 64 || baseButton === 65);
145
+ }),
146
+ );
147
+ }
148
+
149
+ function isAtTranscriptBottom(tui: any): boolean {
150
+ // 惰性 Proxy fullscreen:官方 viewport 以 isFollowingOutput 判定是否在底部。
151
+ if (fullscreenLazyTui(tui)) return isFullscreenAtBottom(tui);
152
+ return true;
153
+ }
154
+
155
+ export function hideScrollButton(tui: any): void {
156
+ const changed = getScrollButtonVisible() || getScrollButtonHovered();
157
+ setScrollButtonVisible(false);
158
+ setScrollButtonHovered(false);
159
+ if (changed) tui.requestRender?.();
160
+ }
161
+
162
+ export function scheduleScrollButtonSync(tui: any, data: string): void {
163
+ if (
164
+ !fullscreenLazyTui(tui) ||
165
+ !toolMouseInteractionActive() ||
166
+ !isScrollNavigationInput(data) ||
167
+ scrollButtonSyncScheduled
168
+ )
169
+ return;
170
+ scrollButtonSyncScheduled = true;
171
+ const previousLines = tui.previousLines;
172
+ const check = (attempt: number) => {
173
+ scrollButtonSyncScheduled = false;
174
+ if (toolMouseTui !== tui) return;
175
+ // Pi renders on its own frame timer. Inspect the resulting viewport before
176
+ // showing the button so empty or non-scrollable transcripts never flash it.
177
+ const rendered = tui.previousLines !== previousLines;
178
+ // fullscreen 下 isFollowingOutput 是即时状态,无需等待官方帧渲染。
179
+ if (!rendered && attempt < 4 && !fullscreenLazyTui(tui)) {
180
+ scrollButtonSyncScheduled = true;
181
+ const timer = setTimeout(() => check(attempt + 1), 16);
182
+ if (typeof timer === "object" && timer !== null && "unref" in timer) {
183
+ (timer as { unref: () => void }).unref();
184
+ }
185
+ return;
186
+ }
187
+ const nextVisible = !isAtTranscriptBottom(tui);
188
+ if (nextVisible !== getScrollButtonVisible()) {
189
+ setScrollButtonVisible(nextVisible);
190
+ tui.requestRender?.();
191
+ }
192
+ };
193
+ process.nextTick(() => check(0));
194
+ }
195
+
196
+ export function updateScrollButtonFromInput(tui: any, data: string): void {
197
+ if (!fullscreenLazyTui(tui) || !toolMouseInteractionActive()) return;
198
+ if (matchesKey(data, "enter") || matchesKey(data, "return"))
199
+ hideScrollButton(tui);
200
+ }
201
+
202
+ export function renderScrollButton(width: number, theme: any): string[] {
203
+ if (!getScrollButtonVisible() || !fullscreenLazyTui(toolMouseTui)) return [];
204
+ const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
205
+ const label = theme.fg(
206
+ getScrollButtonHovered() ? "text" : "accent",
207
+ `[ ↓ Back to bottom · ${shortcut} ]`,
208
+ );
209
+ const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
210
+ return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
211
+ }
@@ -0,0 +1,28 @@
1
+ # Attribution
2
+
3
+ The rich diff renderer in this directory is adapted from
4
+ [MasuRii/pi-tool-display](https://github.com/MasuRii/pi-tool-display) at commit
5
+ `91cef7580078371f8dc49a8607222807ad6a424d`.
6
+
7
+ Copyright (c) MasuRii and pi-tool-display contributors. Used under the MIT
8
+ License. The upstream license text is reproduced below.
9
+
10
+ > MIT License
11
+ >
12
+ > Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ > of this software and associated documentation files (the "Software"), to deal
14
+ > in the Software without restriction, including without limitation the rights
15
+ > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ > copies of the Software, and to permit persons to whom the Software is
17
+ > furnished to do so, subject to the following conditions:
18
+ >
19
+ > The above copyright notice and this permission notice shall be included in all
20
+ > copies or substantial portions of the Software.
21
+ >
22
+ > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ > SOFTWARE.
@@ -0,0 +1,128 @@
1
+ const ANSI_SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
2
+ const STYLE_RESET_PARAMS = [39, 22, 23, 24, 25, 27, 28, 29, 59] as const;
3
+
4
+ export { ANSI_SGR_PATTERN, STYLE_RESET_PARAMS };
5
+
6
+ export function expandSgrReset(param: number): readonly number[] | undefined {
7
+ return param === 0 ? STYLE_RESET_PARAMS : undefined;
8
+ }
9
+
10
+ export function toSgrParams(rawParams: string): number[] {
11
+ if (!rawParams.trim()) {
12
+ return [0];
13
+ }
14
+
15
+ const parsed = rawParams
16
+ .split(";")
17
+ .map((token) => Number.parseInt(token, 10))
18
+ .filter((value) => Number.isFinite(value));
19
+
20
+ return parsed.length > 0 ? parsed : [];
21
+ }
22
+
23
+ export function isFiniteSgrParam(value: number | undefined): value is number {
24
+ return typeof value === "number" && Number.isFinite(value);
25
+ }
26
+
27
+ export function readSgrColorSequence(
28
+ params: number[],
29
+ index: number,
30
+ ): number[] | undefined {
31
+ const param = params[index];
32
+ if (param !== 38 && param !== 48) {
33
+ return undefined;
34
+ }
35
+
36
+ const colorMode = params[index + 1];
37
+ if (colorMode === 5) {
38
+ const colorValue = params[index + 2];
39
+ return isFiniteSgrParam(colorValue)
40
+ ? [param, colorMode, colorValue]
41
+ : undefined;
42
+ }
43
+
44
+ if (colorMode === 2) {
45
+ const red = params[index + 2];
46
+ const green = params[index + 3];
47
+ const blue = params[index + 4];
48
+ return isFiniteSgrParam(red) &&
49
+ isFiniteSgrParam(green) &&
50
+ isFiniteSgrParam(blue)
51
+ ? [param, colorMode, red, green, blue]
52
+ : undefined;
53
+ }
54
+
55
+ return undefined;
56
+ }
57
+
58
+ export function stripBackgroundSgrParams(params: readonly number[]): number[] {
59
+ const sanitized: number[] = [];
60
+
61
+ for (let index = 0; index < params.length; index++) {
62
+ const param = params[index] ?? 0;
63
+
64
+ if (param === 0) {
65
+ sanitized.push(...expandSgrReset(param)!);
66
+ continue;
67
+ }
68
+
69
+ if (
70
+ param === 49 ||
71
+ (param >= 40 && param <= 47) ||
72
+ (param >= 100 && param <= 107)
73
+ ) {
74
+ continue;
75
+ }
76
+
77
+ if (param === 38 || param === 48) {
78
+ const sequence = readSgrColorSequence(params as number[], index);
79
+ if (sequence) {
80
+ if (param === 38) {
81
+ sanitized.push(...sequence);
82
+ }
83
+ index += sequence.length - 1;
84
+ continue;
85
+ }
86
+ const advance =
87
+ params[index + 1] === 5 ? 2 : params[index + 1] === 2 ? 4 : 0;
88
+ if (advance > 0) {
89
+ index += advance;
90
+ if (param === 38) {
91
+ sanitized.push(param);
92
+ }
93
+ continue;
94
+ }
95
+ }
96
+
97
+ sanitized.push(param);
98
+ }
99
+
100
+ return sanitized;
101
+ }
102
+
103
+ export function filterSgrSequences(
104
+ text: string,
105
+ filter: (params: number[]) => number[],
106
+ ): string {
107
+ if (!text || !text.includes("\x1b[")) {
108
+ return text;
109
+ }
110
+
111
+ return text.replace(ANSI_SGR_PATTERN, (_sequence, rawParams: string) => {
112
+ const parsed = toSgrParams(rawParams);
113
+ if (parsed.length === 0) {
114
+ return "";
115
+ }
116
+
117
+ const sanitized = filter(parsed);
118
+ if (sanitized.length === 0) {
119
+ return "";
120
+ }
121
+
122
+ return `\x1b[${sanitized.join(";")}m`;
123
+ });
124
+ }
125
+
126
+ export function sanitizeAnsiForThemedOutput(text: string): string {
127
+ return filterSgrSequences(text, stripBackgroundSgrParams);
128
+ }