@clapecho233/pi-smart-fold 0.1.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/README.md +64 -0
- package/index.ts +154 -0
- package/lib/config.ts +51 -0
- package/lib/fold.ts +133 -0
- package/package.json +24 -0
- package/test/fold.test.mjs +175 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# pi-smart-fold
|
|
2
|
+
|
|
3
|
+
[pi](https://github.com/earendil-works/pi-mono) coding-agent 插件:保持会话记录紧凑。
|
|
4
|
+
|
|
5
|
+
- **工具输出默认折叠** — 每次 `session_start`(启动 / `/reload` / `/new` / `/resume` / `/fork`)自动调用 `ctx.ui.setToolsExpanded(false)`,工具输出保持折叠,需要时用 `ctrl+o` 手动展开。
|
|
6
|
+
- **Thinking 折叠为单行、默认滚动显示最后一行** — 通过 pi 的 markdown transformer(`messageType: "assistant-thinking"`)把每个思考块折叠成一行,只显示**最后一行**内容;流式输出时该行随最新内容持续刷新,效果如同 `tail -f`。
|
|
7
|
+
- 截断按**终端显示宽度**计算(中文/emoji 等宽字符占 2 列),超宽时保留行尾并加 `…` 前缀。
|
|
8
|
+
- 折叠仅影响 TUI 显示,不改动会话文件与发送给模型的上下文。
|
|
9
|
+
|
|
10
|
+
## 使用
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
/fold 查看当前状态
|
|
14
|
+
/fold thinking on|off 开关 thinking 折叠(持久化到 smart-fold.config.json)
|
|
15
|
+
/fold tools on|off 开关启动时工具输出折叠(立即作用于当前会话,并持久化)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
配置文件 `smart-fold.config.json`(位于插件目录,可手工编辑):
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"toolsFold": true,
|
|
23
|
+
"thinkingFold": true
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 安装
|
|
28
|
+
|
|
29
|
+
任选其一:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# 方式 A:作为目录插件放入全局自动发现路径
|
|
33
|
+
git clone <this-repo> ~/.pi/agent/extensions/smart-fold
|
|
34
|
+
|
|
35
|
+
# 方式 B:通过 pi 包管理安装
|
|
36
|
+
pi install git:<repo-url>
|
|
37
|
+
|
|
38
|
+
# 方式 C:加入 settings.json
|
|
39
|
+
# ~/.pi/agent/settings.json → { "extensions": ["/path/to/pi-smart-fold"] }
|
|
40
|
+
|
|
41
|
+
# 临时测试
|
|
42
|
+
pi -e /path/to/pi-smart-fold/index.ts
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
> 插件无任何 npm 依赖,pi 通过 jiti 直接加载 TypeScript。
|
|
46
|
+
|
|
47
|
+
## 开发
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm test # 纯函数单元测试(Node ≥ 22.18 原生 TS 类型剥离,无需构建)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
结构:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
index.ts 插件入口(事件 / transformer / /fold 命令)
|
|
57
|
+
lib/fold.ts 纯函数:显示宽度、行尾截断、思考折叠(无依赖、可单测)
|
|
58
|
+
lib/config.ts 配置读写(缺失/损坏时回退默认值)
|
|
59
|
+
test/fold.test.mjs 单元测试
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 兼容性
|
|
63
|
+
|
|
64
|
+
基于 pi `0.85.1` 的公开扩展 API(`registerMarkdownTransformer`、`ctx.ui.setToolsExpanded`、`registerCommand`)。
|
package/index.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-smart-fold
|
|
3
|
+
* =============
|
|
4
|
+
* A pi (https://github.com/earendil-works/pi-mono) coding-agent extension that
|
|
5
|
+
* keeps the transcript compact:
|
|
6
|
+
*
|
|
7
|
+
* 1. Tool output is collapsed on every session start (`session_start` covers
|
|
8
|
+
* startup, /reload, /new, /resume and /fork).
|
|
9
|
+
* 2. Every assistant *thinking* block is folded down to a single line that
|
|
10
|
+
* shows its LAST line. While the model streams, the folded line keeps
|
|
11
|
+
* updating to the newest text — an auto-scrolling "tail -f" effect.
|
|
12
|
+
*
|
|
13
|
+
* The full thinking text is never modified on disk or in the LLM context:
|
|
14
|
+
* folding is display-only, applied through pi's markdown-transformer hook.
|
|
15
|
+
*
|
|
16
|
+
* Runtime control:
|
|
17
|
+
* /fold show current state
|
|
18
|
+
* /fold thinking on|off fold / unfold thinking blocks (persisted)
|
|
19
|
+
* /fold tools on|off collapse / expand tool output (persisted, and
|
|
20
|
+
* applied to the current session immediately)
|
|
21
|
+
*
|
|
22
|
+
* Config file: `smart-fold.config.json` next to this entry file.
|
|
23
|
+
*/
|
|
24
|
+
import { dirname } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { collapseThinking } from "./lib/fold.ts";
|
|
28
|
+
import { loadConfig, saveConfig, type SmartFoldConfig } from "./lib/config.ts";
|
|
29
|
+
|
|
30
|
+
/** Best-effort resolve of this extension's directory (for the config file). */
|
|
31
|
+
function resolveExtensionDir(): string | undefined {
|
|
32
|
+
try {
|
|
33
|
+
// jiti (pi's TS loader) shims import.meta.url for extension modules.
|
|
34
|
+
const url = import.meta.url;
|
|
35
|
+
if (typeof url === "string" && url.startsWith("file:")) {
|
|
36
|
+
return dirname(fileURLToPath(url));
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// fall through
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
// jiti CJS interop fallback
|
|
43
|
+
if (typeof __filename === "string") return dirname(__filename);
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function smartFold(pi: ExtensionAPI): void {
|
|
51
|
+
const extensionDir = resolveExtensionDir();
|
|
52
|
+
const config: SmartFoldConfig = extensionDir ? loadConfig(extensionDir) : { toolsFold: true, thinkingFold: true };
|
|
53
|
+
|
|
54
|
+
// Runtime state (starts from persisted config; /fold mutates + persists).
|
|
55
|
+
let thinkingFold = config.thinkingFold;
|
|
56
|
+
|
|
57
|
+
// -------------------------------------------------------------------------
|
|
58
|
+
// 1) Fold thinking blocks to their last line (display-only).
|
|
59
|
+
// -------------------------------------------------------------------------
|
|
60
|
+
// The transformer runs whenever an assistant message (re)renders — including
|
|
61
|
+
// every streaming update — so the folded line automatically "scrolls" to the
|
|
62
|
+
// latest thinking text while the model works.
|
|
63
|
+
pi.registerMarkdownTransformer((markdown, context) => {
|
|
64
|
+
if (context.messageType !== "assistant-thinking") return markdown;
|
|
65
|
+
if (!thinkingFold) return markdown;
|
|
66
|
+
// -1 gives the renderer a little slack for padding/borders; collapseThinking
|
|
67
|
+
// sanitizes the width itself.
|
|
68
|
+
return collapseThinking(markdown, context.availableWidth - 1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// -------------------------------------------------------------------------
|
|
72
|
+
// 2) Collapse tool output on session start.
|
|
73
|
+
// -------------------------------------------------------------------------
|
|
74
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
75
|
+
if (!config.toolsFold) return;
|
|
76
|
+
if (!ctx.hasUI) return; // no-op guard for print / json modes
|
|
77
|
+
try {
|
|
78
|
+
ctx.ui.setToolsExpanded(false);
|
|
79
|
+
} catch {
|
|
80
|
+
// Never let folding break a session start.
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// -------------------------------------------------------------------------
|
|
85
|
+
// 3) /fold command — inspect and toggle at runtime.
|
|
86
|
+
// -------------------------------------------------------------------------
|
|
87
|
+
const persist = (next: SmartFoldConfig): void => {
|
|
88
|
+
if (extensionDir) saveConfig(extensionDir, next);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const statusLine = (): string =>
|
|
92
|
+
`smart-fold — thinking: ${thinkingFold ? "折叠(folded)" : "展开(full)"} · 工具输出: ${
|
|
93
|
+
config.toolsFold ? "启动时折叠(collapsed)" : "启动时展开(expanded)"
|
|
94
|
+
}`;
|
|
95
|
+
|
|
96
|
+
pi.registerCommand("fold", {
|
|
97
|
+
description: "smart-fold: 查看/切换 thinking 折叠与工具输出折叠 (on|off)",
|
|
98
|
+
handler: async (args, ctx) => {
|
|
99
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
100
|
+
const target = parts[0]?.toLowerCase();
|
|
101
|
+
const value = parts[1]?.toLowerCase();
|
|
102
|
+
|
|
103
|
+
if (target === "thinking") {
|
|
104
|
+
if (value !== "on" && value !== "off") {
|
|
105
|
+
ctx.ui.notify("用法: /fold thinking on|off", "warning");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
thinkingFold = value === "on";
|
|
109
|
+
config.thinkingFold = thinkingFold;
|
|
110
|
+
persist(config);
|
|
111
|
+
if (ctx.mode === "tui") {
|
|
112
|
+
try {
|
|
113
|
+
// Force every rendered assistant message to rebuild through the
|
|
114
|
+
// markdown transformer so the toggle applies to history too.
|
|
115
|
+
// (Resets a custom hidden-thinking label to its default, if any.)
|
|
116
|
+
ctx.ui.setHiddenThinkingLabel();
|
|
117
|
+
} catch {
|
|
118
|
+
// New content still picks the change up on next render.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
ctx.ui.notify(`thinking: ${thinkingFold ? "折叠(folded)" : "展开(full)"} — 已保存`, "info");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (target === "tools") {
|
|
126
|
+
if (value !== "on" && value !== "off") {
|
|
127
|
+
ctx.ui.notify("用法: /fold tools on|off", "warning");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
config.toolsFold = value === "on";
|
|
131
|
+
persist(config);
|
|
132
|
+
if (ctx.hasUI) {
|
|
133
|
+
try {
|
|
134
|
+
ctx.ui.setToolsExpanded(!config.toolsFold); // on => collapsed
|
|
135
|
+
} catch {
|
|
136
|
+
// Applied on next session_start anyway.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
ctx.ui.notify(
|
|
140
|
+
`工具输出: ${config.toolsFold ? "折叠(collapsed)" : "展开(expanded)"} — 已保存`,
|
|
141
|
+
"info",
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (target !== undefined) {
|
|
147
|
+
ctx.ui.notify("用法: /fold [thinking|tools] [on|off]", "warning");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
ctx.ui.notify(statusLine(), "info");
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
package/lib/config.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-smart-fold — config persistence.
|
|
3
|
+
*
|
|
4
|
+
* The config lives next to the extension entry (`smart-fold.config.json`).
|
|
5
|
+
* Every read/write is best-effort: a missing or broken file simply falls
|
|
6
|
+
* back to defaults instead of breaking pi startup.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export interface SmartFoldConfig {
|
|
12
|
+
/** Collapse tool output at every session start. Default: true */
|
|
13
|
+
toolsFold: boolean;
|
|
14
|
+
/** Fold each thinking block down to its (live) last line. Default: true */
|
|
15
|
+
thinkingFold: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const defaultConfig: SmartFoldConfig = {
|
|
19
|
+
toolsFold: true,
|
|
20
|
+
thinkingFold: true,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function configFilePath(extensionDir: string): string {
|
|
24
|
+
return join(extensionDir, "smart-fold.config.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Load config merged over defaults; never throws. */
|
|
28
|
+
export function loadConfig(extensionDir: string): SmartFoldConfig {
|
|
29
|
+
try {
|
|
30
|
+
const file = configFilePath(extensionDir);
|
|
31
|
+
if (!existsSync(file)) return { ...defaultConfig };
|
|
32
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<SmartFoldConfig>;
|
|
33
|
+
return {
|
|
34
|
+
toolsFold: typeof parsed.toolsFold === "boolean" ? parsed.toolsFold : defaultConfig.toolsFold,
|
|
35
|
+
thinkingFold:
|
|
36
|
+
typeof parsed.thinkingFold === "boolean" ? parsed.thinkingFold : defaultConfig.thinkingFold,
|
|
37
|
+
};
|
|
38
|
+
} catch {
|
|
39
|
+
return { ...defaultConfig };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Persist config best-effort. Returns true on success. */
|
|
44
|
+
export function saveConfig(extensionDir: string, config: SmartFoldConfig): boolean {
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(configFilePath(extensionDir), JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/lib/fold.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-smart-fold — pure folding helpers.
|
|
3
|
+
*
|
|
4
|
+
* No pi imports here: this module stays dependency-free and unit-testable
|
|
5
|
+
* with plain `node` (Node >= 23 type stripping; no build step needed).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Regex for CSI / OSC escape sequences — ignored when measuring width. */
|
|
9
|
+
const ANSI_PATTERN =
|
|
10
|
+
/(?:\u001b\[[0-9;?]*[A-Za-z])|(?:\u001b\][^\u0007]*(?:\u0007|\u001b\\))/g;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Display width of a single code point.
|
|
14
|
+
* East Asian wide/fullwidth characters and emoji count as 2 columns,
|
|
15
|
+
* combining marks and zero-width characters count as 0.
|
|
16
|
+
* Best-effort approximation — good enough for one-line truncation.
|
|
17
|
+
*/
|
|
18
|
+
export function codePointWidth(cp: number): number {
|
|
19
|
+
// Zero-width: combining diacritics, ZWSP, BOM, variation selectors
|
|
20
|
+
if (
|
|
21
|
+
cp === 0x200b ||
|
|
22
|
+
cp === 0xfeff ||
|
|
23
|
+
(cp >= 0x0300 && cp <= 0x036f) ||
|
|
24
|
+
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
|
25
|
+
(cp >= 0xfe00 && cp <= 0xfe0f)
|
|
26
|
+
) {
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
// Wide: Hangul Jamo, CJK radicals/symbols, kana, Yi, Hangul syllables,
|
|
30
|
+
// CJK ideographs (incl. ext A/B+), compat ideographs/forms, fullwidth forms,
|
|
31
|
+
// common emoji planes
|
|
32
|
+
if (
|
|
33
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
34
|
+
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
35
|
+
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
36
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
37
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
38
|
+
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
39
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
40
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
41
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
42
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
43
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
44
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) ||
|
|
45
|
+
(cp >= 0x1f680 && cp <= 0x1f6ff) ||
|
|
46
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff) ||
|
|
47
|
+
(cp >= 0x1fa70 && cp <= 0x1faff) ||
|
|
48
|
+
(cp >= 0x20000 && cp <= 0x3fffd)
|
|
49
|
+
) {
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Terminal display width of a string, in columns (ANSI escapes ignored). */
|
|
56
|
+
export function displayWidth(input: string): number {
|
|
57
|
+
let width = 0;
|
|
58
|
+
for (const ch of input.replace(ANSI_PATTERN, "")) {
|
|
59
|
+
width += codePointWidth(ch.codePointAt(0) ?? 0);
|
|
60
|
+
}
|
|
61
|
+
return width;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Keep only the tail of `input` that fits within `maxWidth` display columns.
|
|
66
|
+
* When content is cut, an ellipsis `…` is prefixed (reserving 1 column).
|
|
67
|
+
*/
|
|
68
|
+
export function tailFit(input: string, maxWidth: number): string {
|
|
69
|
+
if (!Number.isFinite(maxWidth)) return input;
|
|
70
|
+
if (maxWidth < 1) return "";
|
|
71
|
+
if (displayWidth(input) <= maxWidth) return input;
|
|
72
|
+
|
|
73
|
+
const budget = maxWidth - 1; // reserve one column for the ellipsis
|
|
74
|
+
if (budget < 1) return "…";
|
|
75
|
+
|
|
76
|
+
const chars = Array.from(input);
|
|
77
|
+
let used = 0;
|
|
78
|
+
let start = chars.length;
|
|
79
|
+
for (let i = chars.length - 1; i >= 0; i--) {
|
|
80
|
+
const w = codePointWidth(chars[i].codePointAt(0) ?? 0);
|
|
81
|
+
if (used + w > budget) break;
|
|
82
|
+
used += w;
|
|
83
|
+
start = i;
|
|
84
|
+
}
|
|
85
|
+
return "…" + chars.slice(start).join("");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Last line of a markdown string whose trimmed content is non-empty. */
|
|
89
|
+
export function lastNonEmptyLine(markdown: string): string {
|
|
90
|
+
const lines = markdown.split(/\r?\n/);
|
|
91
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
92
|
+
if (lines[i].trim() !== "") return lines[i];
|
|
93
|
+
}
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Strip common block-level markdown markers (`#`, `>`, `-`, `*`, `1.`)
|
|
99
|
+
* so the collapsed line reads like prose. Runs a few passes for nesting
|
|
100
|
+
* like `> - item`. Code fence markers are removed since they cannot
|
|
101
|
+
* render usefully on a single collapsed line.
|
|
102
|
+
*/
|
|
103
|
+
export function stripBlockMarkers(raw: string): string {
|
|
104
|
+
let line = raw.trim();
|
|
105
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
106
|
+
const next = line
|
|
107
|
+
.replace(/^#{1,6}\s+/, "")
|
|
108
|
+
.replace(/^>\s+/, "")
|
|
109
|
+
.replace(/^[-*+]\s+/, "")
|
|
110
|
+
.replace(/^\d{1,9}[.)]\s+/, "");
|
|
111
|
+
if (next === line) break;
|
|
112
|
+
line = next;
|
|
113
|
+
}
|
|
114
|
+
line = line.replace(/`{3,}/g, "");
|
|
115
|
+
return line.trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Collapse a thinking markdown block into a single line:
|
|
120
|
+
* take its last non-empty line, strip block markers, and tail-truncate
|
|
121
|
+
* to `availableWidth` display columns. Falls back to the original
|
|
122
|
+
* markdown when there is nothing to show.
|
|
123
|
+
*/
|
|
124
|
+
export function collapseThinking(markdown: string, availableWidth: number): string {
|
|
125
|
+
const line = stripBlockMarkers(lastNonEmptyLine(markdown));
|
|
126
|
+
if (!line) return markdown;
|
|
127
|
+
// Sanitize the width: renderers should always pass a positive number, but a
|
|
128
|
+
// missing/NaN value must degrade to a sane default instead of leaking NaN.
|
|
129
|
+
const width = Number.isFinite(availableWidth) && availableWidth >= 8
|
|
130
|
+
? Math.floor(availableWidth)
|
|
131
|
+
: 80;
|
|
132
|
+
return tailFit(line, width);
|
|
133
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clapecho233/pi-smart-fold",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "pi coding-agent extension: collapse tool output at startup and fold thinking blocks to a live last line",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"pi-package"
|
|
11
|
+
],
|
|
12
|
+
"main": "index.ts",
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": [
|
|
15
|
+
"./index.ts"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node test/fold.test.mjs"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22.18"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free tests for pi-smart-fold's pure helpers.
|
|
3
|
+
* Run: npm test (needs Node >= 22.18 for native .ts type stripping)
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
codePointWidth,
|
|
12
|
+
displayWidth,
|
|
13
|
+
tailFit,
|
|
14
|
+
lastNonEmptyLine,
|
|
15
|
+
stripBlockMarkers,
|
|
16
|
+
collapseThinking,
|
|
17
|
+
} from "../lib/fold.ts";
|
|
18
|
+
import { defaultConfig, loadConfig, saveConfig } from "../lib/config.ts";
|
|
19
|
+
|
|
20
|
+
let passed = 0;
|
|
21
|
+
const check = (name, fn) => {
|
|
22
|
+
try {
|
|
23
|
+
fn();
|
|
24
|
+
passed++;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error(`✗ ${name}`);
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------- width ----
|
|
32
|
+
check("codePointWidth: ascii is 1", () => {
|
|
33
|
+
assert.equal(codePointWidth("a".codePointAt(0)), 1);
|
|
34
|
+
});
|
|
35
|
+
check("codePointWidth: CJK is 2", () => {
|
|
36
|
+
assert.equal(codePointWidth("中".codePointAt(0)), 2);
|
|
37
|
+
assert.equal(codePointWidth("あ".codePointAt(0)), 2);
|
|
38
|
+
assert.equal(codePointWidth("한".codePointAt(0)), 2);
|
|
39
|
+
});
|
|
40
|
+
check("codePointWidth: emoji is 2", () => {
|
|
41
|
+
assert.equal(codePointWidth("😀".codePointAt(0)), 2);
|
|
42
|
+
});
|
|
43
|
+
check("codePointWidth: combining mark is 0", () => {
|
|
44
|
+
assert.equal(codePointWidth(0x0301), 0);
|
|
45
|
+
});
|
|
46
|
+
check("displayWidth: mixed CJK/ascii", () => {
|
|
47
|
+
assert.equal(displayWidth("abc中de"), 7); // 3 + 2 + 2
|
|
48
|
+
});
|
|
49
|
+
check("displayWidth: ignores ANSI escapes", () => {
|
|
50
|
+
assert.equal(displayWidth("\u001b[31mabc\u001b[0m"), 3);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ------------------------------------------------------------- tailFit ----
|
|
54
|
+
check("tailFit: fits → unchanged", () => {
|
|
55
|
+
assert.equal(tailFit("hello", 10), "hello");
|
|
56
|
+
});
|
|
57
|
+
check("tailFit: exact fit → unchanged", () => {
|
|
58
|
+
assert.equal(tailFit("hello", 5), "hello");
|
|
59
|
+
});
|
|
60
|
+
check("tailFit: keeps the tail with ellipsis", () => {
|
|
61
|
+
assert.equal(tailFit("abcdefghij", 5), "…ghij");
|
|
62
|
+
});
|
|
63
|
+
check("tailFit: CJK truncation counts columns", () => {
|
|
64
|
+
assert.equal(tailFit("一二三四五", 7), "…三四五");
|
|
65
|
+
assert.equal(displayWidth(tailFit("一二三四五", 7)), 7);
|
|
66
|
+
});
|
|
67
|
+
check("tailFit: never splits a wide char", () => {
|
|
68
|
+
const out = tailFit("一二三四五", 6);
|
|
69
|
+
assert.equal(displayWidth(out) <= 6, true);
|
|
70
|
+
assert.equal(out.startsWith("…"), true);
|
|
71
|
+
});
|
|
72
|
+
check("tailFit: maxWidth < 1 → empty", () => {
|
|
73
|
+
assert.equal(tailFit("abc", 0), "");
|
|
74
|
+
});
|
|
75
|
+
check("tailFit: non-finite width passes through", () => {
|
|
76
|
+
assert.equal(tailFit("hello", Number.NaN), "hello");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ----------------------------------------------------- lastNonEmptyLine ----
|
|
80
|
+
check("lastNonEmptyLine: picks last non-empty", () => {
|
|
81
|
+
assert.equal(lastNonEmptyLine("first\nsecond\n\n"), "second");
|
|
82
|
+
});
|
|
83
|
+
check("lastNonEmptyLine: skips whitespace lines", () => {
|
|
84
|
+
assert.equal(lastNonEmptyLine("a\n \n b \n\t\n"), " b ");
|
|
85
|
+
});
|
|
86
|
+
check("lastNonEmptyLine: all empty → empty string", () => {
|
|
87
|
+
assert.equal(lastNonEmptyLine("\n \n"), "");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------- stripBlockMarkers ----
|
|
91
|
+
check("stripBlockMarkers: heading", () => {
|
|
92
|
+
assert.equal(stripBlockMarkers("## Heading"), "Heading");
|
|
93
|
+
});
|
|
94
|
+
check("stripBlockMarkers: blockquote", () => {
|
|
95
|
+
assert.equal(stripBlockMarkers("> quoted text"), "quoted text");
|
|
96
|
+
});
|
|
97
|
+
check("stripBlockMarkers: bullet", () => {
|
|
98
|
+
assert.equal(stripBlockMarkers("- item"), "item");
|
|
99
|
+
assert.equal(stripBlockMarkers("* item"), "item");
|
|
100
|
+
});
|
|
101
|
+
check("stripBlockMarkers: ordered list", () => {
|
|
102
|
+
assert.equal(stripBlockMarkers("12. step"), "step");
|
|
103
|
+
});
|
|
104
|
+
check("stripBlockMarkers: nested markers", () => {
|
|
105
|
+
assert.equal(stripBlockMarkers("> - nested"), "nested");
|
|
106
|
+
});
|
|
107
|
+
check("stripBlockMarkers: removes code fences", () => {
|
|
108
|
+
assert.equal(stripBlockMarkers("```ts"), "ts");
|
|
109
|
+
});
|
|
110
|
+
check("stripBlockMarkers: leaves math-ish '>' alone", () => {
|
|
111
|
+
assert.equal(stripBlockMarkers(">= 5"), ">= 5");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------- collapseThinking ----
|
|
115
|
+
check("collapseThinking: folds to last line", () => {
|
|
116
|
+
assert.equal(collapseThinking("first thought\nsecond thought\nthird", 80), "third");
|
|
117
|
+
});
|
|
118
|
+
check("collapseThinking: strips block markers", () => {
|
|
119
|
+
assert.equal(collapseThinking("plan:\n- [ ] do the thing", 80), "[ ] do the thing");
|
|
120
|
+
});
|
|
121
|
+
check("collapseThinking: empty input passes through", () => {
|
|
122
|
+
assert.equal(collapseThinking("", 80), "");
|
|
123
|
+
assert.equal(collapseThinking("\n \n", 80), "\n \n");
|
|
124
|
+
});
|
|
125
|
+
check("collapseThinking: truncates to width, keeping the tail", () => {
|
|
126
|
+
const out = collapseThinking("start\n" + "x".repeat(200), 21);
|
|
127
|
+
assert.equal(displayWidth(out) <= 21, true);
|
|
128
|
+
assert.equal(out.startsWith("…"), true);
|
|
129
|
+
assert.equal(out.endsWith("xxxxxxxxxxxxxxxxxx"), true);
|
|
130
|
+
});
|
|
131
|
+
check("collapseThinking: CJK line truncated by display columns", () => {
|
|
132
|
+
const out = collapseThinking("思考开始\n这是一段很长很长的思考内容", 11);
|
|
133
|
+
assert.equal(displayWidth(out) <= 11, true);
|
|
134
|
+
assert.equal(out.startsWith("…"), true);
|
|
135
|
+
});
|
|
136
|
+
check("collapseThinking: clamps tiny widths", () => {
|
|
137
|
+
assert.equal(typeof collapseThinking("a\nb", 0), "string");
|
|
138
|
+
});
|
|
139
|
+
check("collapseThinking: non-finite width degrades to default 80", () => {
|
|
140
|
+
assert.equal(collapseThinking("a\nb", Number.NaN), "b");
|
|
141
|
+
assert.equal(collapseThinking("a\nb", undefined), "b");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// -------------------------------------------------------------- config ----
|
|
145
|
+
check("config: defaults when file missing", () => {
|
|
146
|
+
const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
|
|
147
|
+
try {
|
|
148
|
+
assert.deepEqual(loadConfig(dir), defaultConfig);
|
|
149
|
+
} finally {
|
|
150
|
+
rmSync(dir, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
check("config: save/load roundtrip and partial merge", () => {
|
|
154
|
+
const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
|
|
155
|
+
try {
|
|
156
|
+
assert.equal(saveConfig(dir, { toolsFold: false, thinkingFold: true }), true);
|
|
157
|
+
assert.deepEqual(loadConfig(dir), { toolsFold: false, thinkingFold: true });
|
|
158
|
+
// partial file merges over defaults
|
|
159
|
+
writeFileSync(join(dir, "smart-fold.config.json"), '{"thinkingFold": false}', "utf8");
|
|
160
|
+
assert.deepEqual(loadConfig(dir), { toolsFold: true, thinkingFold: false });
|
|
161
|
+
} finally {
|
|
162
|
+
rmSync(dir, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
check("config: broken JSON falls back to defaults", () => {
|
|
166
|
+
const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
|
|
167
|
+
try {
|
|
168
|
+
writeFileSync(join(dir, "smart-fold.config.json"), "{oops", "utf8");
|
|
169
|
+
assert.deepEqual(loadConfig(dir), defaultConfig);
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(dir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
console.log(`✓ ${passed} test groups passed`);
|