@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 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`);