@bachi/pi-coder 1.0.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 +24 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/AGENTS.md +100 -0
- package/config/pi-statusline.json +140 -0
- package/config/settings.json +38 -0
- package/config/web-search.json +5 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +123 -0
- package/docs/development.md +177 -0
- package/docs/extensions.md +292 -0
- package/docs/handbook.zh.md +432 -0
- package/docs/installation.md +124 -0
- package/docs/themes.md +107 -0
- package/extensions/ask-user-question/answers.test.ts +104 -0
- package/extensions/ask-user-question/answers.ts +72 -0
- package/extensions/ask-user-question/dialog.test.ts +180 -0
- package/extensions/ask-user-question/dialog.ts +102 -0
- package/extensions/ask-user-question/index.ts +253 -0
- package/extensions/ask-user-question/model.test.ts +275 -0
- package/extensions/ask-user-question/model.ts +259 -0
- package/extensions/ask-user-question/schema.ts +49 -0
- package/extensions/ask-user-question/types.ts +86 -0
- package/extensions/ask-user-question/validate.test.ts +183 -0
- package/extensions/ask-user-question/validate.ts +110 -0
- package/extensions/ask-user-question/view.ts +262 -0
- package/extensions/auto-default-model/default-model.test.ts +268 -0
- package/extensions/auto-default-model/index.ts +87 -0
- package/extensions/bash-command-collapse.ts +1476 -0
- package/extensions/below-editor-after-statusline.ts +118 -0
- package/extensions/clear-command.ts +29 -0
- package/extensions/cwd-statusline.ts +39 -0
- package/extensions/exit-command.ts +59 -0
- package/extensions/fenceless-code-block/index.test.ts +208 -0
- package/extensions/fenceless-code-block/index.ts +28 -0
- package/extensions/fenceless-code-block/render.test.ts +177 -0
- package/extensions/fenceless-code-block/render.ts +142 -0
- package/extensions/folder-history.ts +197 -0
- package/extensions/init-command.ts +163 -0
- package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
- package/extensions/prompt-editor/bash-prompt.ts +59 -0
- package/extensions/prompt-editor/render.test.ts +283 -0
- package/extensions/prompt-editor.ts +212 -0
- package/extensions/read-path-collapse.ts +474 -0
- package/extensions/recap/index.test.ts +348 -0
- package/extensions/recap/index.ts +462 -0
- package/extensions/recap/subagents.test.ts +144 -0
- package/extensions/recap/subagents.ts +128 -0
- package/extensions/rewind/README.md +229 -0
- package/extensions/rewind/checkpoints.test.ts +560 -0
- package/extensions/rewind/checkpoints.ts +820 -0
- package/extensions/rewind/flow.test.ts +756 -0
- package/extensions/rewind/flow.ts +362 -0
- package/extensions/rewind/index.ts +400 -0
- package/extensions/rewind/picker.ts +135 -0
- package/extensions/rewind/viewport.test.ts +76 -0
- package/extensions/rewind/viewport.ts +48 -0
- package/extensions/simple-task/gap.test.ts +147 -0
- package/extensions/simple-task/gap.ts +122 -0
- package/extensions/simple-task/index.ts +439 -0
- package/extensions/simple-task/types.ts +53 -0
- package/extensions/simple-task/widget.ts +86 -0
- package/extensions/startup-logo/header-guard.test.ts +274 -0
- package/extensions/startup-logo/header-guard.ts +166 -0
- package/extensions/startup-logo/index.test.ts +305 -0
- package/extensions/startup-logo/index.ts +194 -0
- package/extensions/startup-logo/loaded-sections.test.ts +257 -0
- package/extensions/startup-logo/loaded-sections.ts +267 -0
- package/extensions/startup-logo/logo.test.ts +124 -0
- package/extensions/startup-logo/logo.ts +124 -0
- package/extensions/statusline/footer-guard.test.ts +273 -0
- package/extensions/statusline/footer-guard.ts +171 -0
- package/extensions/statusline/git.test.ts +174 -0
- package/extensions/statusline/git.ts +142 -0
- package/extensions/statusline/index.ts +294 -0
- package/extensions/statusline/line.test.ts +316 -0
- package/extensions/statusline/line.ts +201 -0
- package/extensions/subagent-log-guard/filter.test.ts +85 -0
- package/extensions/subagent-log-guard/filter.ts +32 -0
- package/extensions/subagent-log-guard/index.ts +112 -0
- package/extensions/theme-command.ts +263 -0
- package/extensions/thinking-collapse/window.test.ts +321 -0
- package/extensions/thinking-collapse/window.ts +354 -0
- package/extensions/thinking-collapse.ts +60 -0
- package/extensions/tool-diff/title-row.test.ts +254 -0
- package/extensions/tool-diff/title-row.ts +191 -0
- package/extensions/tool-diff.ts +1276 -0
- package/extensions/working-indicator/bash-spinner.test.ts +135 -0
- package/extensions/working-indicator/bash-spinner.ts +114 -0
- package/extensions/working-indicator/index.test.ts +579 -0
- package/extensions/working-indicator/index.ts +940 -0
- package/extensions/working-indicator/spinner-frames.test.ts +219 -0
- package/extensions/working-indicator/spinner-frames.ts +156 -0
- package/extensions/working-indicator/summary-request.test.ts +195 -0
- package/extensions/working-indicator/summary-request.ts +207 -0
- package/extensions/working-indicator/working-summary.test.ts +499 -0
- package/extensions/working-indicator/working-summary.ts +375 -0
- package/package.json +71 -0
- package/themes/ayu.json +97 -0
- package/themes/catppuccin.json +103 -0
- package/themes/summer-night.json +87 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* logo.ts — 启动 logo 的字形(静态)
|
|
3
|
+
*
|
|
4
|
+
* 字形是 `curl -fsSL https://pi.dev/install.sh | sh` 顶部那只 π 印记,取自
|
|
5
|
+
* `npm:pi-claude-code-tui@0.1.14`(它自己也是照抄安装脚本)。上游把它做成 22 帧动画,
|
|
6
|
+
* 本仓库只要一只静态 logo,所以这里**只留落定态那一格的形状**,帧表、笔画推进、底座闪动
|
|
7
|
+
* 全部去掉。
|
|
8
|
+
*
|
|
9
|
+
* 形状用 4×4 的布尔格(一格 = 三个 `█`,实心 / 空格),比上游那张「1 基坐标 + 逐帧取色」
|
|
10
|
+
* 的表好读也好测。
|
|
11
|
+
*
|
|
12
|
+
* **刻意补了上游漏掉的一格**:上游落定帧(phase 6)的格子表是
|
|
13
|
+
* `3,2 3,3 3,4 4,2 4,4 5,2 5,3 6,2 6,5`,而它前几帧(phase 4/5 以及白闪帧)都带 `(5,5)`。
|
|
14
|
+
* 少了这格,右腿会断成「悬空的一只脚」,静态显示时特别明显(动画里一闪而过没人注意),
|
|
15
|
+
* 显然是上游那张表的笔误。这里按 `(5,5)` 在场的那版形状画。
|
|
16
|
+
*
|
|
17
|
+
* 本文件不 import pi / pi-tui:颜色由 `index.ts` 用 `theme.fg("accent", …)` 现取,
|
|
18
|
+
* 所以 `/theme` 换肤能跟随;也让 `node --test clients/pi/extensions/startup-logo/*.test.ts` 能直接跑。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 一格 = 三个块字符。 */
|
|
22
|
+
export const MARK_CELL = "███";
|
|
23
|
+
|
|
24
|
+
/** 空格占位(与 `MARK_CELL` 等宽,保证每行可见宽度一致,侧栏才对得齐)。 */
|
|
25
|
+
export const MARK_BLANK = " ".repeat(MARK_CELL.length);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 印记左侧留白:每行前面一个空格。
|
|
29
|
+
*
|
|
30
|
+
* 顶格贴着终端最左边看着太挤,统一缩进一格;这一格算进 `MARK_WIDTH`,侧栏列与窄终端判定
|
|
31
|
+
* 都跟着走,调用方不需要另补。
|
|
32
|
+
*/
|
|
33
|
+
export const MARK_INDENT = " ";
|
|
34
|
+
|
|
35
|
+
/** 4×4 布尔格,1 = 实心。行序自上而下,列序自左而右。 */
|
|
36
|
+
const MARK_GRID: readonly (readonly (0 | 1)[])[] = [
|
|
37
|
+
[1, 1, 1, 0],
|
|
38
|
+
[1, 0, 1, 0],
|
|
39
|
+
[1, 1, 0, 1],
|
|
40
|
+
[1, 0, 0, 1],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** 印记高度(行)。 */
|
|
44
|
+
export const MARK_ROWS = MARK_GRID.length;
|
|
45
|
+
|
|
46
|
+
/** 印记本体宽度(列)× 每格字符数 = 12。 */
|
|
47
|
+
export const MARK_COLS = MARK_GRID[0]!.length;
|
|
48
|
+
export const MARK_GLYPH_WIDTH = MARK_COLS * MARK_CELL.length;
|
|
49
|
+
|
|
50
|
+
/** 每行可见宽度(含左侧留白)= 13。 */
|
|
51
|
+
export const MARK_WIDTH = MARK_INDENT.length + MARK_GLYPH_WIDTH;
|
|
52
|
+
|
|
53
|
+
/** 单格查询(越界当作空格),给单测和调试用。 */
|
|
54
|
+
export function markCellFilled(row: number, col: number): boolean {
|
|
55
|
+
return MARK_GRID[row]?.[col] === 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 印记行的画笔:收「这一格实不实心」,返回**可见宽度为 3** 的字符串。
|
|
60
|
+
*
|
|
61
|
+
* 刻意做成逐格而不是「整行交给一个 paint」:整行包色会把行尾那几个占位空格也吃进色块里,
|
|
62
|
+
* 于是「每行可见宽度 == MARK_WIDTH」这个不变量在带色版本上就不成立了(侧栏列会歪)。
|
|
63
|
+
*
|
|
64
|
+
* 默认画笔返回未着色的 `███` / 空格,方便单测。
|
|
65
|
+
*/
|
|
66
|
+
export type MarkCellPainter = (filled: boolean) => string;
|
|
67
|
+
|
|
68
|
+
export function markLines(paintCell: MarkCellPainter = (filled) => (filled ? MARK_CELL : MARK_BLANK)): string[] {
|
|
69
|
+
return MARK_GRID.map((row) => MARK_INDENT + row.map((filled) => paintCell(filled === 1)).join(""));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 把侧栏文字(已着色)贴到印记右侧,**垂直居中**(2 条落在第 1、2 行)。
|
|
74
|
+
*
|
|
75
|
+
* 前提:传进来的行**可见宽度已等于 `MARK_WIDTH`**(`markLines` 保证),所以直接接在行尾就
|
|
76
|
+
* 对得齐,这里不按字符串长度补 —— 行里有 ANSI 时按长度补一定歪。左侧留白(`MARK_INDENT`)
|
|
77
|
+
* 已经算在 `MARK_WIDTH` 里,所以侧栏列也跟着右移一格,不需要在这里额外处理。
|
|
78
|
+
*
|
|
79
|
+
* **不裁行尾占位空格**:它们是「每行 `MARK_WIDTH` 列」这个不变量的一部分。上游那种整行包色的写法
|
|
80
|
+
* 在这里也不成立(行尾是 ANSI 重置符而不是空白,`trimEnd` 会静默变成空操作),所以统一不裁,
|
|
81
|
+
* 让行为与调用方怎么上色无关。这些空格没有底色,终端里不可见,pi 也照样按宽度截断。
|
|
82
|
+
*/
|
|
83
|
+
export function attachSideText(lines: readonly string[], side: readonly string[], gap = 2): string[] {
|
|
84
|
+
if (lines.length === 0) return [];
|
|
85
|
+
const start = Math.max(0, Math.floor((lines.length - side.length) / 2));
|
|
86
|
+
return lines.map((line, i) => {
|
|
87
|
+
const text = side[i - start];
|
|
88
|
+
return i >= start && text ? `${line}${" ".repeat(gap)}${text}` : line;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface HeaderSections {
|
|
93
|
+
/** 已着色的 logo 行(含侧栏文字)。 */
|
|
94
|
+
logo: readonly string[];
|
|
95
|
+
/** 已着色的快捷键提示行(内置 header 那一行紧凑版)。 */
|
|
96
|
+
hints?: string;
|
|
97
|
+
/** 已着色的说明行。 */
|
|
98
|
+
onboarding?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 完整 header 行序:logo → 空行 → 提示 → 空行 → 说明。
|
|
103
|
+
* 缺某段就不留多余空行(窄终端回退时 logo 段为空)。
|
|
104
|
+
*/
|
|
105
|
+
export function composeHeaderLines(sections: HeaderSections): string[] {
|
|
106
|
+
const lines = [...sections.logo];
|
|
107
|
+
if (sections.hints) {
|
|
108
|
+
if (lines.length > 0) lines.push("");
|
|
109
|
+
lines.push(sections.hints);
|
|
110
|
+
}
|
|
111
|
+
if (sections.onboarding) {
|
|
112
|
+
if (lines.length > 0) lines.push("");
|
|
113
|
+
lines.push(sections.onboarding);
|
|
114
|
+
}
|
|
115
|
+
return lines;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 绝对路径缩短:家目录内显示成 `~/...`。 */
|
|
119
|
+
export function shortenPath(cwd: string, home: string | undefined): string {
|
|
120
|
+
if (!home) return cwd;
|
|
121
|
+
if (cwd === home) return "~";
|
|
122
|
+
if (cwd.startsWith(`${home}/`)) return `~${cwd.slice(home.length)}`;
|
|
123
|
+
return cwd;
|
|
124
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
FOOTER_GUARD_KEY,
|
|
5
|
+
findRenderContainer,
|
|
6
|
+
freezeFooterContainer,
|
|
7
|
+
releaseFooterGuard,
|
|
8
|
+
type RenderableContainer,
|
|
9
|
+
} from "./footer-guard.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 仿 pi-tui 的 `Container`:`clear()` 会**换掉**整个 children 数组
|
|
13
|
+
* (`this.children = []`),所以守卫必须每次重读该字段而不是缓存它。
|
|
14
|
+
*/
|
|
15
|
+
class FakeContainer implements RenderableContainer {
|
|
16
|
+
children: unknown[] = [];
|
|
17
|
+
/** 原 render 被调用的次数(断言「默认 footer 一帧都没出」用)。 */
|
|
18
|
+
originalRenders = 0;
|
|
19
|
+
|
|
20
|
+
addChild(component: unknown): void {
|
|
21
|
+
this.children.push(component);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
clear(): void {
|
|
25
|
+
this.children = [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render(width: number): string[] {
|
|
29
|
+
this.originalRenders += 1;
|
|
30
|
+
return this.children.flatMap((child) => (child as Leaf).render(width));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 仿叶子组件:行里带上自己的名字和宽度,方便看出行是谁、按哪个宽度渲染的。 */
|
|
35
|
+
interface Leaf {
|
|
36
|
+
render(width: number): string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function leaf(name: string, lines = 1): Leaf {
|
|
40
|
+
return { render: (width: number) => Array.from({ length: lines }, () => `${name}#${width}`) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 仿 `tui`:只有 children,没有 render(findRenderContainer 只认 children)。 */
|
|
44
|
+
function tuiRoot(...children: unknown[]): { children: unknown[] } {
|
|
45
|
+
return { children };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("findRenderContainer 按对象身份认出直接持有它的容器", () => {
|
|
49
|
+
const ours = leaf("statusline");
|
|
50
|
+
const inner = new FakeContainer();
|
|
51
|
+
inner.addChild(ours);
|
|
52
|
+
const outer = new FakeContainer();
|
|
53
|
+
outer.addChild(inner);
|
|
54
|
+
const root = tuiRoot(new FakeContainer(), outer);
|
|
55
|
+
|
|
56
|
+
assert.equal(findRenderContainer(root, ours), inner);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("findRenderContainer 找不到就返回 undefined,不猜位置", () => {
|
|
60
|
+
const inner = new FakeContainer();
|
|
61
|
+
inner.addChild(leaf("other"));
|
|
62
|
+
const root = tuiRoot(inner);
|
|
63
|
+
|
|
64
|
+
assert.equal(findRenderContainer(root, leaf("missing")), undefined);
|
|
65
|
+
assert.equal(findRenderContainer(root, undefined), undefined);
|
|
66
|
+
assert.equal(findRenderContainer(undefined, leaf("x")), undefined);
|
|
67
|
+
assert.equal(findRenderContainer({ notAContainer: true }, leaf("x")), undefined);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("findRenderContainer 容忍环状引用与只读 children", () => {
|
|
71
|
+
const a = new FakeContainer();
|
|
72
|
+
const b = new FakeContainer();
|
|
73
|
+
a.addChild(b);
|
|
74
|
+
b.addChild(a);
|
|
75
|
+
|
|
76
|
+
assert.equal(findRenderContainer(a, leaf("nope")), undefined);
|
|
77
|
+
|
|
78
|
+
const frozenRoot = tuiRoot({ children: Object.freeze([leaf("x")]) as unknown[] });
|
|
79
|
+
assert.equal(findRenderContainer(frozenRoot, leaf("y")), undefined);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("我们的组件在位时原样透传,被换成内置 footer 时出重放行", () => {
|
|
83
|
+
const ours = leaf("statusline");
|
|
84
|
+
const builtIn = leaf("pi-default");
|
|
85
|
+
const container = new FakeContainer();
|
|
86
|
+
container.addChild(ours);
|
|
87
|
+
|
|
88
|
+
const release = freezeFooterContainer({
|
|
89
|
+
container,
|
|
90
|
+
ownComponent: ours,
|
|
91
|
+
frozenRender: (width) => [`frozen#${width}`],
|
|
92
|
+
});
|
|
93
|
+
assert.equal(typeof release, "function");
|
|
94
|
+
|
|
95
|
+
// 正常会话:真实渲染照旧。
|
|
96
|
+
assert.deepEqual(container.render(80), ["statusline#80"]);
|
|
97
|
+
assert.equal(container.originalRenders, 1);
|
|
98
|
+
|
|
99
|
+
// 换会话窗口:pi 已经 clear() 并挂上内置 footer。
|
|
100
|
+
container.clear();
|
|
101
|
+
container.addChild(builtIn);
|
|
102
|
+
assert.deepEqual(container.render(80), ["frozen#80"]);
|
|
103
|
+
assert.deepEqual(container.render(40), ["frozen#40"]);
|
|
104
|
+
// 内置 footer 一次都没被渲染 → 屏幕上不会出现默认 footer。
|
|
105
|
+
assert.equal(container.originalRenders, 1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("重放行按当前宽度重新截断", () => {
|
|
109
|
+
const ours = leaf("statusline");
|
|
110
|
+
const container = new FakeContainer();
|
|
111
|
+
container.addChild(ours);
|
|
112
|
+
const wide = " ".repeat(30);
|
|
113
|
+
freezeFooterContainer({
|
|
114
|
+
container,
|
|
115
|
+
ownComponent: ours,
|
|
116
|
+
frozenRender: (width) => [wide.slice(0, width)],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
container.clear();
|
|
120
|
+
container.addChild(leaf("pi-default"));
|
|
121
|
+
assert.deepEqual(container.render(10), [wide.slice(0, 10)]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("解除接管后恢复原渲染,且不留 own render 属性与接管记录", () => {
|
|
125
|
+
const ours = leaf("statusline");
|
|
126
|
+
const builtIn = leaf("pi-default");
|
|
127
|
+
const container = new FakeContainer();
|
|
128
|
+
container.addChild(ours);
|
|
129
|
+
|
|
130
|
+
const release = freezeFooterContainer({
|
|
131
|
+
container,
|
|
132
|
+
ownComponent: ours,
|
|
133
|
+
frozenRender: () => ["frozen"],
|
|
134
|
+
})!;
|
|
135
|
+
container.clear();
|
|
136
|
+
container.addChild(builtIn);
|
|
137
|
+
assert.deepEqual(container.render(10), ["frozen"]);
|
|
138
|
+
|
|
139
|
+
release();
|
|
140
|
+
assert.equal(Object.prototype.hasOwnProperty.call(container, "render"), false);
|
|
141
|
+
assert.equal((container as { [FOOTER_GUARD_KEY]?: unknown })[FOOTER_GUARD_KEY], undefined);
|
|
142
|
+
assert.deepEqual(container.render(10), ["pi-default#10"]);
|
|
143
|
+
|
|
144
|
+
// 幂等:重复解除不再改动任何东西。
|
|
145
|
+
release();
|
|
146
|
+
assert.equal(Object.prototype.hasOwnProperty.call(container, "render"), false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("对同一容器重复接管会先交还上一次,不叠两层", () => {
|
|
150
|
+
const first = leaf("first");
|
|
151
|
+
const second = leaf("second");
|
|
152
|
+
const container = new FakeContainer();
|
|
153
|
+
container.addChild(first);
|
|
154
|
+
container.addChild(second);
|
|
155
|
+
|
|
156
|
+
freezeFooterContainer({ container, ownComponent: first, frozenRender: () => ["frozen-first"] });
|
|
157
|
+
freezeFooterContainer({ container, ownComponent: second, frozenRender: () => ["frozen-second"] });
|
|
158
|
+
|
|
159
|
+
// first 在位、second 不在位:若两层的 wrapper 还叠着,这里会出 frozen-first;
|
|
160
|
+
// 只保留最后一次接管 → 判定 second 不在位 → 出 frozen-second。
|
|
161
|
+
container.clear();
|
|
162
|
+
container.addChild(first);
|
|
163
|
+
assert.deepEqual(container.render(7), ["frozen-second"]);
|
|
164
|
+
|
|
165
|
+
// second 回到位 → 透传。
|
|
166
|
+
container.addChild(second);
|
|
167
|
+
assert.deepEqual(container.render(7), ["first#7", "second#7"]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("超时后自愈:组件在位与否都交还原渲染", () => {
|
|
171
|
+
const ours = leaf("statusline");
|
|
172
|
+
const builtIn = leaf("pi-default");
|
|
173
|
+
const container = new FakeContainer();
|
|
174
|
+
container.addChild(ours);
|
|
175
|
+
let clock = 1000;
|
|
176
|
+
const release = freezeFooterContainer({
|
|
177
|
+
container,
|
|
178
|
+
ownComponent: ours,
|
|
179
|
+
frozenRender: () => ["frozen"],
|
|
180
|
+
maxAgeMs: 500,
|
|
181
|
+
now: () => clock,
|
|
182
|
+
})!;
|
|
183
|
+
|
|
184
|
+
// 组件仍在新位置(正常交接后的样子)+ 已超时 → 透传并交还。
|
|
185
|
+
clock = 1600;
|
|
186
|
+
assert.deepEqual(container.render(9), ["statusline#9"]);
|
|
187
|
+
assert.equal(Object.prototype.hasOwnProperty.call(container, "render"), false);
|
|
188
|
+
|
|
189
|
+
// 另一侧:新 footer 一直没来(换会话失败)→ 超时后回到 pi 默认行为。
|
|
190
|
+
const ours2 = leaf("statusline2");
|
|
191
|
+
const container2 = new FakeContainer();
|
|
192
|
+
container2.addChild(ours2);
|
|
193
|
+
let clock2 = 0;
|
|
194
|
+
freezeFooterContainer({
|
|
195
|
+
container: container2,
|
|
196
|
+
ownComponent: ours2,
|
|
197
|
+
frozenRender: () => ["frozen"],
|
|
198
|
+
maxAgeMs: 500,
|
|
199
|
+
now: () => clock2,
|
|
200
|
+
});
|
|
201
|
+
container2.clear();
|
|
202
|
+
container2.addChild(builtIn);
|
|
203
|
+
clock2 = 501;
|
|
204
|
+
assert.deepEqual(container2.render(9), ["pi-default#9"]);
|
|
205
|
+
release();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("未超时期间无论多少帧都只出重放行", () => {
|
|
209
|
+
const ours = leaf("statusline");
|
|
210
|
+
const container = new FakeContainer();
|
|
211
|
+
container.addChild(ours);
|
|
212
|
+
freezeFooterContainer({
|
|
213
|
+
container,
|
|
214
|
+
ownComponent: ours,
|
|
215
|
+
frozenRender: (width) => [`frozen#${width}`, `frozen#${width}`],
|
|
216
|
+
});
|
|
217
|
+
container.clear();
|
|
218
|
+
container.addChild(leaf("pi-default"));
|
|
219
|
+
|
|
220
|
+
for (let frame = 0; frame < 5; frame += 1) {
|
|
221
|
+
assert.deepEqual(container.render(80), ["frozen#80", "frozen#80"]);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("容器形状不对时什么都不做(非 tui 模式 / pi 改了结构)", () => {
|
|
226
|
+
const noop = () => ["frozen"];
|
|
227
|
+
assert.equal(freezeFooterContainer({ container: undefined, ownComponent: {}, frozenRender: noop }), undefined);
|
|
228
|
+
assert.equal(freezeFooterContainer({ container: {}, ownComponent: {}, frozenRender: noop }), undefined);
|
|
229
|
+
assert.equal(
|
|
230
|
+
freezeFooterContainer({ container: { children: [] }, ownComponent: {}, frozenRender: noop }),
|
|
231
|
+
undefined,
|
|
232
|
+
);
|
|
233
|
+
assert.equal(
|
|
234
|
+
freezeFooterContainer({
|
|
235
|
+
container: { children: "not-an-array", render: noop } as unknown as RenderableContainer,
|
|
236
|
+
ownComponent: {},
|
|
237
|
+
frozenRender: noop,
|
|
238
|
+
}),
|
|
239
|
+
undefined,
|
|
240
|
+
);
|
|
241
|
+
assert.equal(freezeFooterContainer({ container: new FakeContainer(), ownComponent: undefined, frozenRender: noop }), undefined);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("接管过程中容器不可写时不抛错", () => {
|
|
245
|
+
const container = new FakeContainer();
|
|
246
|
+
const ours = leaf("statusline");
|
|
247
|
+
container.addChild(ours);
|
|
248
|
+
Object.defineProperty(container, "render", {
|
|
249
|
+
configurable: false,
|
|
250
|
+
writable: false,
|
|
251
|
+
value: (width: number) => [`locked#${width}`],
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
assert.equal(
|
|
255
|
+
freezeFooterContainer({ container, ownComponent: ours, frozenRender: () => ["frozen"] }),
|
|
256
|
+
undefined,
|
|
257
|
+
);
|
|
258
|
+
assert.deepEqual(container.render(4), ["locked#4"]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("releaseFooterGuard 能解除别人(别的扩展实例)留下的接管", () => {
|
|
262
|
+
const ours = leaf("statusline");
|
|
263
|
+
const builtIn = leaf("pi-default");
|
|
264
|
+
const container = new FakeContainer();
|
|
265
|
+
container.addChild(ours);
|
|
266
|
+
freezeFooterContainer({ container, ownComponent: ours, frozenRender: () => ["frozen"] });
|
|
267
|
+
|
|
268
|
+
assert.equal(releaseFooterGuard(container), true);
|
|
269
|
+
assert.equal(releaseFooterGuard(container), false);
|
|
270
|
+
container.clear();
|
|
271
|
+
container.addChild(builtIn);
|
|
272
|
+
assert.deepEqual(container.render(3), ["pi-default#3"]);
|
|
273
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* footer-guard.ts — 会话替换期间的 footer「冻结」
|
|
3
|
+
*
|
|
4
|
+
* 要解决的问题:pi 每次换会话(`/new`、`/clear`、`/resume`、fork、rewind)都会在
|
|
5
|
+
* `session_shutdown` 之后、新会话 `session_start` 之前调 `resetExtensionUI()`,它无条件
|
|
6
|
+
* `setExtensionFooter(undefined)` 把 footer 还原成内置那只、并 `clearExtensionStatuses()`
|
|
7
|
+
* 清掉所有 `setStatus` 文本(`interactive-mode.js`)。中间要新建会话对象、重绑扩展,
|
|
8
|
+
* 几十毫秒起步,而 pi-tui 的渲染节流只有 16ms(`TuiBase.MIN_RENDER_INTERVAL_MS`),
|
|
9
|
+
* 所以这段窗口至少会出几帧 —— 用 `ctx.ui.setFooter()` 装的 statusline 就「闪」成
|
|
10
|
+
* pi 默认 footer,第二行(cwd / checkpoint)整行消失。
|
|
11
|
+
*
|
|
12
|
+
* 为什么只能在渲染路径上解决:扩展侧没有更早的钩子。新扩展实例要等
|
|
13
|
+
* `bindCurrentSessionExtensions()` 才拿到 ctx,而那正是窗口结束的时刻;在窗口内主动
|
|
14
|
+
* 重挂 footer 也不可靠(`resetExtensionUI` 发生在我们的 `session_shutdown` 回调**之后**,
|
|
15
|
+
* 且整条链上有多个 await,`setImmediate` / 微任务谁先谁后取决于别的扩展在这段里等多久)。
|
|
16
|
+
* 所以保证落在**出帧那一刻**:接管 footer 容器的 `render`,发现里面装的已经不是我们的
|
|
17
|
+
* 组件时,返回上一帧的行 —— 默认 footer 一帧都不会出现。
|
|
18
|
+
*
|
|
19
|
+
* 做法上的约束(和本目录其它文件一致):
|
|
20
|
+
* - 只替换 footer 容器的 `render` 方法,不改 `children`、不猜下标、不碰 pi 的内部状态;
|
|
21
|
+
* - 容器用**对象身份**从 `tui.children` 找(`findRenderContainer`),找不到就什么都不做;
|
|
22
|
+
* - 任何一步抛错都当作「没冻住」,绝不把异常漏给宿主弹 extension error;
|
|
23
|
+
* - 接管记录挂在容器的 `FOOTER_GUARD_KEY`(`Symbol.for`,所以 `/reload` 后新的扩展实例
|
|
24
|
+
* 也能看到并解除旧实例遗留的接管);另有 `maxAgeMs` 兜底,交接万一失败也只冻一小会儿。
|
|
25
|
+
*
|
|
26
|
+
* 已知小代价:冻结期间该容器的鼠标命中测试(`Container.handleMouse` 读 `children`)会按
|
|
27
|
+
* 内置 footer 的高度算,而窗口只有一两帧、footer 本来不可点,可忽略。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** 挂在 footer 容器上的接管记录键(全局符号注册表:跨扩展实例可见)。 */
|
|
31
|
+
export const FOOTER_GUARD_KEY: symbol = Symbol.for("litellm-any.pi-statusline.footerGuard");
|
|
32
|
+
|
|
33
|
+
/** 冻结的最长时长:正常情况下新会话的 `session_start` 会远早于它解除。 */
|
|
34
|
+
export const FOOTER_GUARD_MAX_AGE_MS = 3000;
|
|
35
|
+
|
|
36
|
+
/** pi-tui `Container` 里本模块用到的那部分结构(`children` 是公开字段)。 */
|
|
37
|
+
export interface RenderableContainer {
|
|
38
|
+
children: unknown[];
|
|
39
|
+
render(width: number): string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface GuardRecord {
|
|
43
|
+
release: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 在 `root` 的子树里按对象身份找「直接装着 `component` 的那个容器」。
|
|
48
|
+
* 只认对象身份(同形状的组件到处都是),带 seen 集与深度上限,任何异常都当作没找到。
|
|
49
|
+
*/
|
|
50
|
+
export function findRenderContainer(
|
|
51
|
+
root: unknown,
|
|
52
|
+
component: unknown,
|
|
53
|
+
maxDepth = 6,
|
|
54
|
+
): RenderableContainer | undefined {
|
|
55
|
+
if (component === undefined || component === null) return undefined;
|
|
56
|
+
const seen = new Set<unknown>();
|
|
57
|
+
|
|
58
|
+
const walk = (node: unknown, depth: number): RenderableContainer | undefined => {
|
|
59
|
+
if (depth > maxDepth || node === null || typeof node !== "object" || seen.has(node)) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
seen.add(node);
|
|
63
|
+
const children = (node as { children?: unknown }).children;
|
|
64
|
+
if (!Array.isArray(children)) return undefined;
|
|
65
|
+
if (children.includes(component)) return node as RenderableContainer;
|
|
66
|
+
for (const child of children) {
|
|
67
|
+
const found = walk(child, depth + 1);
|
|
68
|
+
if (found) return found;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
return walk(root, 0);
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 解除 `container` 上现有的接管(可能是别的扩展实例遗留的)。返回是否解掉了什么。 */
|
|
81
|
+
export function releaseFooterGuard(container: unknown): boolean {
|
|
82
|
+
try {
|
|
83
|
+
const record = (container as { [FOOTER_GUARD_KEY]?: GuardRecord } | undefined)?.[FOOTER_GUARD_KEY];
|
|
84
|
+
if (!record || typeof record.release !== "function") return false;
|
|
85
|
+
record.release();
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 接管 footer 容器的渲染,返回解除函数(幂等)。
|
|
94
|
+
*
|
|
95
|
+
* - `ownComponent` 还在容器里 → 原样透传(并在超时后顺手交还,自愈);
|
|
96
|
+
* - 容器被 pi 换成内置 footer → 返回 `frozenRender(width)` 的行;
|
|
97
|
+
* - 超过 `maxAgeMs` 仍等不到新 footer → 交还,回到 pi 默认行为。
|
|
98
|
+
*/
|
|
99
|
+
export function freezeFooterContainer(options: {
|
|
100
|
+
container: unknown;
|
|
101
|
+
ownComponent: unknown;
|
|
102
|
+
frozenRender: (width: number) => string[];
|
|
103
|
+
maxAgeMs?: number;
|
|
104
|
+
now?: () => number;
|
|
105
|
+
}): (() => void) | undefined {
|
|
106
|
+
const container = options.container as RenderableContainer | undefined;
|
|
107
|
+
if (
|
|
108
|
+
!container ||
|
|
109
|
+
typeof container.render !== "function" ||
|
|
110
|
+
!Array.isArray(container.children) ||
|
|
111
|
+
options.ownComponent === undefined
|
|
112
|
+
) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
// 先解除已有接管(自己重入 / 上一个扩展实例遗留),避免叠两层 wrapper。
|
|
116
|
+
releaseFooterGuard(container);
|
|
117
|
+
|
|
118
|
+
const ownComponent = options.ownComponent;
|
|
119
|
+
const maxAgeMs = options.maxAgeMs ?? FOOTER_GUARD_MAX_AGE_MS;
|
|
120
|
+
const now = options.now ?? Date.now;
|
|
121
|
+
const armedAt = now();
|
|
122
|
+
const hadOwnRender = Object.prototype.hasOwnProperty.call(container, "render");
|
|
123
|
+
const originalRender = container.render;
|
|
124
|
+
const state: { released: boolean; record?: GuardRecord } = { released: false };
|
|
125
|
+
|
|
126
|
+
const release = (): void => {
|
|
127
|
+
if (state.released) return;
|
|
128
|
+
state.released = true;
|
|
129
|
+
try {
|
|
130
|
+
const holder = container as { [FOOTER_GUARD_KEY]?: GuardRecord };
|
|
131
|
+
if (state.record && holder[FOOTER_GUARD_KEY] === state.record) delete holder[FOOTER_GUARD_KEY];
|
|
132
|
+
if (hadOwnRender) container.render = originalRender;
|
|
133
|
+
else delete (container as { render?: unknown }).render;
|
|
134
|
+
} catch {
|
|
135
|
+
// 容器只读 / 已被替换:wrapper 自己会因 released 标志透传,不再冻。
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const wrapper = (width: number): string[] => {
|
|
140
|
+
if (state.released) return originalRender.call(container, width);
|
|
141
|
+
const expired = now() - armedAt > maxAgeMs;
|
|
142
|
+
let mounted = false;
|
|
143
|
+
try {
|
|
144
|
+
mounted = Array.isArray(container.children) && container.children.includes(ownComponent);
|
|
145
|
+
} catch {
|
|
146
|
+
mounted = true; // 读 children 都抛:别硬撑,交还给原渲染。
|
|
147
|
+
}
|
|
148
|
+
if (mounted) {
|
|
149
|
+
if (expired) release();
|
|
150
|
+
return originalRender.call(container, width);
|
|
151
|
+
}
|
|
152
|
+
if (expired) {
|
|
153
|
+
release();
|
|
154
|
+
return originalRender.call(container, width);
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return options.frozenRender(width);
|
|
158
|
+
} catch {
|
|
159
|
+
return originalRender.call(container, width);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
container.render = wrapper as RenderableContainer["render"];
|
|
165
|
+
state.record = { release };
|
|
166
|
+
(container as { [FOOTER_GUARD_KEY]?: GuardRecord })[FOOTER_GUARD_KEY] = state.record;
|
|
167
|
+
} catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
return release;
|
|
171
|
+
}
|