@clapecho233/pi-smart-fold 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -1,61 +1,68 @@
1
1
  # pi-smart-fold
2
2
 
3
- [pi](https://github.com/earendil-works/pi-mono) coding-agent 插件:保持会话记录紧凑,同时不丢失关键信息。
3
+ A [pi](https://github.com/earendil-works/pi-mono) coding-agent extension that keeps the session transcript compact — without losing key information.
4
4
 
5
- ## 功能
5
+ | Without smart-fold | With smart-fold |
6
+ | --- | --- |
7
+ | Long thinking walls, verbose tool output, full file previews | One-line `Thought for 12.4s` summaries, folded tool output, `write src/index.ts +12 -3` headers |
6
8
 
7
- ### 1. Thinking 折叠 + 计时(`smart` 默认)
9
+ ## Features
8
10
 
9
- - **思考中**:首行显示加粗的 **`Thinking… (8s)`**,换行后滚动显示思考文本的**结尾**(如 `tail -f`)。**点击一次 → 直接切换为完整显示至今全部思考内容**(持续跟随增长,加粗的实时计时行 **`Thinking… (8s)`** 固定在思考块**最底部**并持续更新);**再点一次 → 回到滚动结尾**。没有中间标签状态,始终在 滚动结尾 ↔ 完整显示 间单击切换:
11
+ ### 1. Thinking fold + live timer (`smart` mode, default)
10
12
 
11
- ```text
12
- 滚动显示(默认) 完整显示(点击后)
13
- Thinking… (8s) …前面的思考内容…
14
- …正在进行的思考最新一行 正在进行的思考……
15
- Thinking… (9s) ← 固定在底部、持续更新
16
- ```
13
+ **While the model thinks**, the block shows a bold **`Thinking… (8s)`** header line, followed by the scrolling *tail* of the thinking text (like `tail -f`). **One click toggles directly to the full text so far** (the ticking **`Thinking… (8s)`** line stays pinned at the bottom and keeps updating); **one more click toggles back** to the scrolling tail. There is no intermediate label state — a single click always switches between `scrolling tail ↔ full view`:
17
14
 
18
- - **思考结束后**:若该段思考当时正处于完整显示,会平滑过渡为底部 **`Thought for 12.4s`** 定格时长行;消息结束后默认完全折叠为一行加粗的 **`Thought for 12.4s`**。
19
- - **单击展开/收起单个思考块**:思考结束后默认进入隐藏态(由原型补丁预置),折叠行就是 **`Thought for 12.4s`** 标签(加粗、带实测时长)。**点击一次 → 该块展开为完整思考全文**(底部保留加粗的时长行,与斜体思考正文风格稍作区分);**再点一次 → 收起**。没有中间状态,只影响被点击的块,同一消息里的其它思考块不受干扰。
20
- - **完全接管原生折叠**:pi 内置的思考隐藏标签(`Thinking...`)被替换为本插件按消息生成的 `Thought for …` 标签,点击与 ctrl+t 出现的都是我们的文本,而不是原生的 `Thinking...`(smart 模式下 ctrl+t 无额外效果,用点击或 `/fold expand` 展开)。
21
- - **点击识别是精确的**:通过对 pi 公开导出的 `AssistantMessageComponent` 打一个幂等的原型补丁,预置完成思考的隐藏态并观测其内部可见性映射的点击写入(区分 ctrl+t 的清空操作);思考中的单击会被重定向(改回可见并切换插件自己的 滚动↔完整 模式),原生两态切换的中间“隐藏标签”态永远不会出现。主题切换 / 窗口缩放 / 布局重绘等全局重渲染绝不会误判为点击;若未来版本无法安装补丁,自动降级为 pi 原生显示(可用 `/fold expand` 兜底)。
22
- - **时长持久化**:每段思考的时长按内容哈希记录进会话文件(`smart-fold-thinking` 自定义条目,不进入 LLM 上下文),`/resume` 恢复会话后依旧显示;同一条消息内多段思考(工具调用之间)分别计时。
23
- - 截断按**终端显示宽度**计算(中文 / emoji 等宽字符占 2 列),超宽时保留行尾并加 `…` 前缀。
24
- - 折叠仅影响 TUI 显示,不改动会话文件中的原文与发送给模型的上下文。
15
+ ```text
16
+ Scrolling tail (default) Full view (after one click)
17
+ Thinking… (8s) …earlier thinking text…
18
+ …latest line of thinking …thinking still streaming…
19
+ Thinking… (9s) ← pinned at the bottom, live-updating
20
+ ```
21
+
22
+ **After the thinking run finishes**, the block collapses to a single bold **`Thought for 12.4s`** line (measured duration). If the block was in full view at that moment, it transitions smoothly to the pinned duration footer first.
23
+
24
+ **Single-click expand/collapse per block**: finished thinking starts hidden (seeded by a prototype patch). **One click → that block expands to its full text** (with the bold duration line kept at the bottom); **one more click → collapses back**. No intermediate states, and only the clicked block is affected — other thinking runs in the same message are untouched. The same one-click toggle works for a run that already finished *while its message is still streaming* (e.g. thinking done, answer text or a tool call still coming): the folded `Thought for …` line expands directly — never through pi's bare `Thought…` label middle state.
25
25
 
26
- 其它模式:`tail`(始终单行显示结尾 + 加粗时长前缀)、`full`(结束后直接显示全文,底部同样附时长行)、`off`(关闭,并恢复 pi 默认的思考显示)。
26
+ - **Fully takes over native folding**: pi's built-in hidden-thinking label (`Thinking...`) is replaced with per-message `Thought for …` labels generated by this extension. What you see after clicks or `ctrl+t` is always our text, never the native one. (In `smart` mode `ctrl+t` has no extra effect — use clicks or `/fold expand` instead.)
27
+ - **Exact click detection**: an idempotent prototype patch on pi's publicly exported `AssistantMessageComponent` seeds the hidden state of finished thinking runs and observes writes to the component's internal visibility map (distinguishing `ctrl+t`'s clear operation). Clicks on runs of a still-streaming message — the live run as well as already-finished ones — are redirected so the native two-state toggle's hidden middle state never appears. Global re-renders — theme changes, window resizes, layout redraws — are never mistaken for clicks. If a future pi version can't be patched, the extension degrades gracefully to pi's native display (with `/fold expand` as a fallback).
28
+ - **Persistent durations**: each thinking run's duration is recorded in the session file keyed by content hash (a `smart-fold-thinking` custom entry, never sent to the LLM context), so durations still show after `/resume`. Multiple thinking runs within one message (between tool calls) are timed individually.
29
+ - **Code-block safe**: while a code fence streams through the thinking text, structural fence lines are skipped in the collapsed tail — the block never flashes fully open for a frame. In the expanded view an unclosed code fence is closed automatically so the `Thinking…/Thought for …` footer renders below it as bold text, never swallowed into the code block as literal `**asterisks**`.
30
+ - Truncation is measured in **terminal display columns** (CJK characters / emoji count as 2), keeping the line ending with an `…` prefix when over-wide.
31
+ - Folding is **display-only**: the session file and the context sent to the model are never modified.
27
32
 
28
- 备用:`/fold expand on|off`(或设置面板中的"展开全部思考")临时展开/折叠全部思考块 —— 日常操作直接点击单个思考块即可,新会话默认全部折叠。
33
+ Other modes: `tail` (always one line — the text tail with a bold duration prefix), `full` (show full text after the run ends, with the duration footer), `off` (disable and restore pi's default thinking display).
29
34
 
30
- ### 2. 工具输出折叠
35
+ Fallback: `/fold expand on|off` (or "Expand all thinking" in the settings panel) temporarily expands/collapses *all* finished thinking blocks. Daily usage only needs single clicks on individual blocks; new sessions always start fully folded.
31
36
 
32
- 每次 `session_start`(启动 / `/reload` / `/new` / `/resume` / `/fork`)自动调用 `ctx.ui.setToolsExpanded(false)`,工具输出保持折叠,`ctrl+o` 手动展开。
37
+ ### 2. Tool output folding
33
38
 
34
- ### 3. 工具调用行截断 + Write/Edit 增删统计
39
+ On every `session_start` (startup, `/reload`, `/new`, `/resume`, `/fork`) the extension calls `ctx.ui.setToolsExpanded(false)` so tool output stays collapsed; press `ctrl+o` to expand manually.
35
40
 
36
- - **调用命令过长时截断**:`bash`/`read`/`grep`/`find`/`ls` 等工具的调用行在折叠状态下截断为单行,超宽时以 `…` 结尾(pi 自带的 ANSI 感知 `truncateToWidth`),命令还有后续行时追加 ` …` 标记;点击或 `ctrl+o` 展开后与工具输出一起完整显示。
37
- - **write / edit 增删统计**:
38
- - write:执行**前**读取原文件内容,与写入内容做行级 diff(公共前后缀裁剪 + LCS),首行追加 **绿色 `+新增` / 红色 `-删除`**,如 `write src/index.ts +12 -3`;新文件 `+N -0`;超大文件(>8MB)跳过。统计随工具结果**持久化进会话文件**,恢复历史会话后依旧显示(本版本之前产生的旧写入没有记录,不显示)。
39
- - edit:直接对各 `edits` 的 `oldText → newText` 做行级 diff 并求和,如 `edit src/app.ts +2 -1`(参数流式传输时就实时更新)。
40
- - **write/edit 默认完全折叠,不显示任何代码内容/差异**:折叠时调用区和结果区都只剩一行头部(`writeCollapsed: header` 默认;pi 原生没有此能力,通过对 renderCall + renderResult 的双重包装实现 —— 结果区的 diff 在折叠时返回空组件,错误信息仍会显示);点击 / `ctrl+o` 展开后显示完整差异 / 语法高亮内容,**头部行在折叠和展开时都保留 `+N -M` 统计**(edit 自带外壳的背景条完整保留,统计注入行尾填充、行宽不变)。可在设置中改回 `preview` 恢复 pi 原生预览。
41
- - 实现方式为对 pi 内置工具的渲染包装(执行逻辑完全复用各 `create*ToolDefinition`)。
41
+ ### 3. Tool call line truncation + write/edit diff stats
42
42
 
43
- ## 设置界面
43
+ - **Long call lines are truncated**: `bash` / `read` / `grep` / `find` / `ls` call lines collapse to a single line, cut to the terminal width with a trailing `…` (pi's ANSI-aware `truncateToWidth`). An extra ` …` marker is appended when the command has more lines. Click or `ctrl+o` to expand and see everything.
44
+ - **write / edit line-diff stats**:
45
+ - `write`: reads the original file content *before* the write executes and computes a line-level diff (common prefix/suffix trimming + LCS). The header line gains a **green `+added` / red `-removed`** suffix, e.g. `write src/index.ts +12 -3`; new files show `+N -0`. Files larger than 8 MB are skipped. Stats are **persisted with the tool result in the session file**, so they still show in restored sessions (writes made before this version have no record and show nothing).
46
+ - `edit`: diffs each `edits[]` entry's `oldText → newText` directly and sums the result, e.g. `edit src/app.ts +2 -1` (updates live while arguments stream in).
47
+ - **write/edit collapse to a header-only line by default** (`writeCollapsed: header`): collapsed rows show just the one-line header — no code content or diff preview (not natively possible in pi; implemented by wrapping both `renderCall` and `renderResult` — the result area renders an empty component while collapsed, and error messages remain visible). Click / `ctrl+o` expands to the full diff / syntax-highlighted content. The **`+N -M` stat stays in the header line in both states** (edit's native full-width background bar is preserved; the stat is injected into the trailing padding without changing the line width). Switch to `preview` in settings to restore pi's native preview.
48
+ - Implemented as rendering wrappers around pi's built-in tools (execution logic is fully reused from each `create*ToolDefinition`).
49
+
50
+ ## Settings
44
51
 
45
52
  ```
46
- /fold 打开 /config 样式的选择式设置(Enter/Space 切换值,Esc 关闭)
53
+ /fold Open a /config-style interactive settings list (Enter/Space to change, Esc to close)
47
54
  ```
48
55
 
49
- 也可带参数直接修改(含自动补全):
56
+ Or change values directly (with autocompletion):
50
57
 
51
58
  ```text
52
- /fold thinking smart|tail|full|off # on=smart, off=off 兼容旧写法
53
- /fold tools on|off # 启动时是否折叠工具输出
54
- /fold writestat on|off # write 增删统计开关
55
- /fold writecollapsed header|preview # write 折叠时仅首行 / 保留预览
59
+ /fold thinking smart|tail|full|off # on=smart, off=off accepted for compatibility
60
+ /fold tools on|off # fold tool output at session start
61
+ /fold writestat on|off # write/edit diff stats toggle
62
+ /fold writecollapsed header|preview # write rows: header-only / keep preview when collapsed
56
63
  ```
57
64
 
58
- 配置文件 `smart-fold.config.json`(位于插件目录,可手工编辑;旧的 `thinkingFold` 布尔值会自动迁移):
65
+ Config file `smart-fold.config.json` (next to the extension entry; the legacy boolean `thinkingFold` is migrated automatically):
59
66
 
60
67
  ```json
61
68
  {
@@ -66,52 +73,69 @@
66
73
  }
67
74
  ```
68
75
 
69
- ## 安装
76
+ ## Installation
70
77
 
71
- 任选其一:
78
+ Any one of these:
72
79
 
73
80
  ```bash
74
- # 方式 A:作为目录插件放入全局自动发现路径
81
+ # Option A: clone into pi's global extension auto-discovery directory
75
82
  git clone <this-repo> ~/.pi/agent/extensions/smart-fold
76
83
 
77
- # 方式 B:通过 pi 包管理安装
84
+ # Option B: install via the pi package manager
78
85
  pi install git:<repo-url>
79
86
 
80
- # 方式 C:加入 settings.json
87
+ # Option C: add to settings.json
81
88
  # ~/.pi/agent/settings.json → { "extensions": ["/path/to/pi-smart-fold"] }
82
89
 
83
- # 临时测试
90
+ # Try it temporarily
84
91
  pi -e /path/to/pi-smart-fold/index.ts
85
92
  ```
86
93
 
87
- > 插件无任何 npm 依赖,pi 通过 jiti 直接加载 TypeScript。
94
+ > The extension has no npm dependencies — pi loads the TypeScript directly via jiti.
88
95
 
89
- ## 开发
96
+ ## Development
90
97
 
91
98
  ```bash
92
- npm test # 纯函数单元测试(Node ≥ 22.18 原生 TS 类型剥离,无需构建)
93
- npm run test:sim # 点击周期仿真:驱动真实 pi AssistantMessageComponent
94
- # (需先把已安装的 pi 包符号链接到 node_modules/@earendil-works)
99
+ npm install # dev deps + relink node_modules/@earendil-works to the installed pi (postinstall)
100
+ npm run link:pi # re-point the type/runtime links after a pi upgrade — always current version
101
+ npm run typecheck # strict tsc against the installed pi's .d.ts (no emit)
102
+ npm run check # link + typecheck + jiti load-check + unit tests + click simulation
103
+ npm test # unit tests for the pure functions (native TS type stripping, Node ≥ 22.18, no build step)
104
+ npm run test:sim # click-cycle simulation against the real pi AssistantMessageComponent
95
105
  ```
96
106
 
97
- 结构:
107
+ The extension itself has no npm dependencies — pi loads the TypeScript directly via jiti.
108
+ `typescript` / `@types/node` are dev-only, and `node_modules/@earendil-works/*` are symlinks into
109
+ the pi installation that loads the extension (created by `scripts/link-pi.mjs`), so typecheck
110
+ always runs against the exact pi version installed on the machine.
111
+
112
+ Project layout:
98
113
 
99
114
  ```
100
- index.ts 插件入口(transformer / 事件 / write 渲染包装 / /fold 设置界面)
101
- lib/fold.ts 纯函数:显示宽度、行尾截断、时长格式化、行级 diff
102
- lib/thinking.ts 思考计时状态机(按内容哈希记录各段思考时长)
103
- lib/config.ts 配置读写(含旧配置迁移;缺失/损坏时回退默认值)
104
- test/fold.test.mjs 单元测试
105
- test/click-sim.mjs 点击周期仿真(真实 pi 组件 + 模拟点击,验证 滚动↔完整 切换)
115
+ index.ts Extension entry (transformer / events / write render wrappers / /fold settings UI)
116
+ lib/fold.ts Pure functions: display width, tail truncation, duration formatting, line diff
117
+ lib/thinking.ts Thinking timer state machine (per-run durations keyed by content hash)
118
+ lib/config.ts Config load/save (with legacy migration; falls back to defaults when missing/corrupt)
119
+ scripts/link-pi.mjs Symlink the installed pi runtime into node_modules (version-following)
120
+ scripts/load-check.mjs Smoke test: load the extension via pi's jiti and exercise registration
121
+ test/fold.test.mjs Unit tests
122
+ test/click-sim.mjs Click-cycle simulation (real pi component + simulated clicks, verifies tail ↔ full toggling)
106
123
  ```
107
124
 
108
- ## 已知边界
125
+ ## Known limitations
126
+
127
+ - Single-click toggling of thinking runs in a *live* message depends on the prototype patch's redirect; without the patch it degrades to pi's native two-state toggle (two clicks to expand).
128
+ - Expand state is runtime-only: new sessions/reloads start folded again. Window resizes never collapse blocks you've expanded.
129
+ - write diff stats only apply to writes executed in the current session (restored older sessions have no pre-execution snapshot to diff against).
130
+ - When stacked with pi's native "Hide thinking blocks" (`ctrl+t` toggle): if thinking is hidden that way, the live scrolling line won't show — keep the native display at its default and let this extension handle folding.
109
131
 
110
- - 思考中的单击切换依赖原型补丁的重定向;补丁不可用时降级为 pi 原生两态切换(此时需两次点击展开)。
111
- - 展开状态为运行时状态,新会话/重载后恢复折叠;窗口缩放不会收起你已展开的块。
112
- - write 增删统计只对当前会话中的写入生效(恢复的旧会话没有执行前快照可对比)。
113
- - 与 pi 原生 "Hide thinking blocks"(`ctrl+t` 切换)叠加时,若隐藏了思考块,思考中不会显示滚动行 —— 建议保持默认的显示状态,由本插件负责折叠。
132
+ ## Compatibility
114
133
 
115
- ## 兼容性
134
+ Built and verified against pi **`0.87.1`** public extension APIs: `registerMarkdownTransformer`,
135
+ `ctx.ui.setToolsExpanded` / `setHiddenThinkingLabel`, `create*ToolDefinition` (and friends),
136
+ `registerTool` (`renderCall` / `renderResult` + `ToolRenderContext`), `registerCommand`, `appendEntry`,
137
+ `SettingsList`, and the `AssistantMessageComponent` click-internals patch (`updateContent`,
138
+ `thinkingVisibilityOverrides`, `hiddenThinkingLabel`).
116
139
 
117
- 基于 pi `0.85.1` 的公开扩展 API(`registerMarkdownTransformer`、`ctx.ui.setToolsExpanded`、`createWriteToolDefinition`、`registerCommand`、`appendEntry`、`SettingsList`)。
140
+ After upgrading pi, run `npm run link:pi && npm run check` — the links follow the newly installed
141
+ version automatically, and the check verifies types, jiti loading, and behavior against it.
package/index.ts CHANGED
@@ -73,6 +73,7 @@ import { Container, SettingsList, Text, truncateToWidth } from "@earendil-works/
73
73
  import type { Component, SettingItem } from "@earendil-works/pi-tui";
74
74
 
75
75
  import {
76
+ closeOpenFences,
76
77
  countEditsLineDiff,
77
78
  countLineDiff,
78
79
  displayWidth,
@@ -130,24 +131,29 @@ function resolveExtensionDir(): string | undefined {
130
131
  // (idempotently, shared across extension reloads via Symbol.for keys) to:
131
132
  //
132
133
  // 1. SEED every thinking run of a FINALIZED message with a hidden override
133
- // when it has none yet. Finished thinking therefore starts collapsed in
134
- // the hidden state — and since we also set the component's label text
135
- // per message (`Thought for 12.4s`), the collapsed line keeps its
136
- // duration. One click then flips the run to expanded (our transformer
137
- // renders the full text), one click flips it back. Single click.
134
+ // when it has none yet (runs a streaming-era redirect left visible but
135
+ // folded are normalized into the seeded state as well). Finished
136
+ // thinking therefore starts collapsed in the hidden state — and since we
137
+ // also set the component's label text per message (`Thought for 12.4s`),
138
+ // the collapsed line keeps its duration. One click then flips the run to
139
+ // expanded (our transformer renders the full text), one click flips it
140
+ // back. Single click.
138
141
  // 2. CLEAR the overrides when expand-all is requested (mode "full" or the
139
142
  // expand toggle), so every run renders expanded.
140
143
  // 3. DIFF the overrides map against a per-component snapshot to observe
141
144
  // clicks exactly (seeding performed in the same pass is excluded, and
142
145
  // clears are ignored) and record which block (content hash) was clicked
143
146
  // plus a monotonic click sequence.
144
- // 4. REDIRECT clicks on the run that is still being written. pi's handler
145
- // just toggled that run to the hidden-label state; we flip the override
146
- // straight back to visible and toggle our own tail ↔ full mode instead,
147
- // so one click switches between the scrolling tail and the full text
148
- // (timing line pinned at the bottom) — the hidden middle state never
149
- // shows. The MouseRegion click closure is rebuilt from the corrected
150
- // map on every render, so each later click hits the same redirect.
147
+ // 4. REDIRECT clicks on any thinking run of a STILL-STREAMING message.
148
+ // pi's handler just toggled that run to the hidden-label state; we flip
149
+ // the override straight back to visible and toggle our own mode
150
+ // instead. For the run still being written that switches between the
151
+ // scrolling tail and the full text (timing line pinned at the bottom);
152
+ // for a run that already finished while the message keeps streaming it
153
+ // switches between the folded duration line and the full text with its
154
+ // footer. The MouseRegion click closure is rebuilt from the corrected
155
+ // map on every render, so each later click hits the same redirect and
156
+ // the hidden middle state (the global `Thought…` label) never shows.
151
157
  //
152
158
  // The markdown transformer uses that click signal only to mark a block as
153
159
  // user-revealed (add-only), so unflagged re-renders of expanded blocks keep
@@ -167,8 +173,19 @@ interface SmartFoldGlobalState {
167
173
  * the tracker's open group (unlike `durationFor`).
168
174
  */
169
175
  isLiveRun(runText: string): boolean;
176
+ /**
177
+ * True when a FINISHED run of a still-streaming message currently renders
178
+ * expanded (revealed by a click, or matched by the streaming open prefix,
179
+ * or expand-all/full mode) — mirrors the transformer's `open` condition.
180
+ */
181
+ isFinishedRunOpen(runText: string): boolean;
170
182
  /** Called when the user collapses a live run back to the scrolling tail. */
171
183
  onLiveCollapse?(runText: string): void;
184
+ /**
185
+ * Called when the user collapses a finished run of a still-streaming
186
+ * message back to its folded duration line.
187
+ */
188
+ onFinishedCollapse?(runText: string): void;
172
189
  }
173
190
 
174
191
  type SmartFoldGlobal = typeof globalThis & { [SF_STATE]?: SmartFoldGlobalState };
@@ -213,7 +230,7 @@ function installThinkingDisplayPatch(): void {
213
230
  }
214
231
  ).prototype;
215
232
  const original = proto?.updateContent;
216
- if (typeof original !== "function") return;
233
+ if (!proto || typeof original !== "function") return;
217
234
  if ("__smartFoldPatched" in original) return; // already installed by a previous load
218
235
  try {
219
236
  const patched = function (this: unknown, message: unknown, isStreaming?: boolean) {
@@ -229,6 +246,7 @@ function installThinkingDisplayPatch(): void {
229
246
  const overrides = self.thinkingVisibilityOverrides;
230
247
  if (state && state.behavior !== "inert" && overrides instanceof Map) {
231
248
  const previous = self[OVERRIDE_SNAPSHOT];
249
+ const previousMap = previous === undefined ? undefined : new Map(previous);
232
250
  const seeded = new Set<number>();
233
251
  const finalized =
234
252
  (isStreaming === undefined ? self.isStreaming !== true : isStreaming === false);
@@ -244,6 +262,21 @@ function installThinkingDisplayPatch(): void {
244
262
  if (!overrides.has(run)) {
245
263
  overrides.set(run, true);
246
264
  seeded.add(run);
265
+ } else if (
266
+ overrides.get(run) === false &&
267
+ previousMap?.get(run) === false && // leftover, not a fresh click
268
+ runs[run] !== undefined &&
269
+ state.isFinishedRunOpen?.(runs[run]) !== true
270
+ ) {
271
+ // A streaming-era redirect left this run visible but folded
272
+ // (the user collapsed it again, or it was never opened).
273
+ // Normalize it into the seeded hidden state — the folded
274
+ // line and the per-message label read the same, so without
275
+ // this the next click would appear to change nothing.
276
+ // Marked seeded so the diff below does not mistake the
277
+ // normalization for a click.
278
+ overrides.set(run, true);
279
+ seeded.add(run);
247
280
  }
248
281
  }
249
282
  self.hiddenThinkingLabel = collapsedLabelText(runs, state);
@@ -263,24 +296,32 @@ function installThinkingDisplayPatch(): void {
263
296
  if (changedRun !== null) {
264
297
  const runText = thinkingRunText(self.lastMessage ?? message, changedRun);
265
298
  const clickedHidden = new Map(current).get(changedRun) === true;
266
- if (
267
- clickedHidden &&
268
- !finalized &&
269
- runText !== null &&
270
- state.isLiveRun?.(runText) === true // live = no finalized duration yet
271
- ) {
272
- // Single-click tail ↔ full on the run still being written:
273
- // pi just flipped it to the hidden-label state. Redirect —
274
- // keep the run visible and switch our own mode instead. The
275
- // click closure is rebuilt from the corrected map, so every
276
- // later click lands here again: no hidden middle state.
277
- const openPrefix = openStreamingPrefix();
278
- if (openPrefix !== null && runText.startsWith(openPrefix)) {
279
- state.onLiveCollapse?.(runText); // drop any stale reveal mark
280
- setStreamingOpenPrefix(null); // full → scrolling tail
299
+ if (clickedHidden && !finalized && runText !== null) {
300
+ // A click on a thinking run of a still-streaming message just
301
+ // flipped it to pi's hidden-label state — which would show the
302
+ // global `Thought…` label (per-message labels are only set
303
+ // once finalized). Redirect: keep the run visible and toggle
304
+ // our own mode instead. The click closure is rebuilt from the
305
+ // corrected map, so every later click lands here again — one
306
+ // click per state change, no hidden middle state.
307
+ if (state.isLiveRun?.(runText) === true) {
308
+ // Run still being written: scrolling tail ↔ full text so
309
+ // far, timing line pinned at the bottom of the full view.
310
+ const openPrefix = openStreamingPrefix();
311
+ if (openPrefix !== null && runText.startsWith(openPrefix)) {
312
+ state.onLiveCollapse?.(runText); // drop any stale reveal mark
313
+ setStreamingOpenPrefix(null); // full → scrolling tail
314
+ } else {
315
+ recordRevealClick(runText); // tail → full text so far
316
+ setStreamingOpenPrefix(runText);
317
+ }
318
+ } else if (state.isFinishedRunOpen?.(runText) === true) {
319
+ // Run already finished while the message keeps streaming:
320
+ // full text → folded duration line.
321
+ state.onFinishedCollapse?.(runText);
281
322
  } else {
282
- recordRevealClick(runText); // tail → full text so far
283
- setStreamingOpenPrefix(runText);
323
+ // Finished run, currently folded: → full text + footer.
324
+ recordRevealClick(runText);
284
325
  }
285
326
  overrides.set(changedRun, false);
286
327
  current = Array.from(overrides.entries());
@@ -391,6 +432,16 @@ function clearOpenStreamingPrefix(): void {
391
432
  (globalThis as ClickGlobal)[CLICK_OPEN_PREFIX] = null;
392
433
  }
393
434
 
435
+ /**
436
+ * Forget the last recorded click so a re-render inside the click window
437
+ * cannot re-reveal a block the user just collapsed.
438
+ */
439
+ function clearRevealClick(): void {
440
+ const clickGlobal = globalThis as ClickGlobal;
441
+ clickGlobal[CLICK_AT] = undefined;
442
+ clickGlobal[CLICK_HASH] = undefined;
443
+ }
444
+
394
445
  /** How long after a click its re-render is expected (generous for slow frames). */
395
446
  const CLICK_WINDOW_MS = 250;
396
447
 
@@ -502,8 +553,23 @@ export default function smartFold(pi: ExtensionAPI): void {
502
553
  durationFor: (runText: string) =>
503
554
  tracker.finalizedMs(hashText(runText)) ?? tracker.finalizeIfMatches(runText),
504
555
  isLiveRun: (runText: string) => tracker.finalizedMs(hashText(runText)) === undefined,
556
+ isFinishedRunOpen: (runText: string) => {
557
+ if (revealedBlocks.has(hashText(runText))) return true;
558
+ const prefix = openStreamingPrefix();
559
+ if (prefix !== null && runText.startsWith(prefix)) return true;
560
+ return config.thinking === "full" || expandAllThinking;
561
+ },
505
562
  onLiveCollapse: (runText: string) => {
506
563
  revealedBlocks.delete(hashText(runText));
564
+ clearRevealClick();
565
+ },
566
+ onFinishedCollapse: (runText: string) => {
567
+ revealedBlocks.delete(hashText(runText));
568
+ const prefix = openStreamingPrefix();
569
+ if (prefix !== null && runText.startsWith(prefix)) {
570
+ setStreamingOpenPrefix(null); // that prefix was this run's text
571
+ }
572
+ clearRevealClick();
507
573
  },
508
574
  };
509
575
  };
@@ -596,7 +662,11 @@ export default function smartFold(pi: ExtensionAPI): void {
596
662
  mode === "full" ||
597
663
  expandAllThinking;
598
664
  if (open) {
599
- return markdown.replace(/\s+$/, "") + expandedThinkingSuffix(ms);
665
+ // Close any unclosed code fence so the footer renders below the
666
+ // code block, not inside it as literal `**` characters.
667
+ return (
668
+ closeOpenFences(markdown.replace(/\s+$/, "")) + expandedThinkingSuffix(ms)
669
+ );
600
670
  }
601
671
  return foldedThinkingLine(markdown, ms, width, mode === "tail" ? "tail" : "smart");
602
672
  }
@@ -605,14 +675,20 @@ export default function smartFold(pi: ExtensionAPI): void {
605
675
  // run), with the ticking `Thinking… (Ns)` line pinned at the bottom.
606
676
  const openPrefix = openStreamingPrefix();
607
677
  if (openPrefix !== null && markdown.startsWith(openPrefix)) {
608
- return markdown.replace(/\s+$/, "") + liveExpandedSuffix(tracker.liveElapsedMs());
678
+ // Close any unclosed code fence so the ticking footer stays outside
679
+ // the code block (otherwise it shows literal `**` asterisks).
680
+ return (
681
+ closeOpenFences(markdown.replace(/\s+$/, "")) +
682
+ liveExpandedSuffix(tracker.liveElapsedMs())
683
+ );
609
684
  }
610
685
  return liveThinkingLine(markdown, tracker.liveElapsedMs(), width);
611
686
  }
612
687
 
613
688
  if (mode === "full" || expandAllThinking || revealedBlocks.has(key)) {
614
- // Fully expanded: original text, with the duration footer at the bottom.
615
- return markdown.replace(/\s+$/, "") + expandedThinkingSuffix(ms);
689
+ // Fully expanded: original text, with the duration footer at the bottom
690
+ // (after closing any unclosed code fence so it renders as bold text).
691
+ return closeOpenFences(markdown.replace(/\s+$/, "")) + expandedThinkingSuffix(ms);
616
692
  }
617
693
  return foldedThinkingLine(markdown, ms, width, mode);
618
694
  });
package/lib/fold.ts CHANGED
@@ -105,6 +105,28 @@ export function lastNonEmptyLine(markdown: string): string {
105
105
  return "";
106
106
  }
107
107
 
108
+ /** A markdown code-fence line: up to 3 spaces indent, then ``` or ~~~. */
109
+ const FENCE_LINE = /^\s{0,3}(?:`{3,}|~{3,})/;
110
+
111
+ /**
112
+ * Last line of a markdown string worth showing as a one-line tail: blank
113
+ * lines and structural code-fence lines are skipped, and lines that strip
114
+ * down to nothing (bare block markers) are skipped too. Returns "" when
115
+ * there is no such line — e.g. while the model is right at a code fence
116
+ * being streamed.
117
+ */
118
+ export function lastMeaningfulLine(markdown: string): string {
119
+ const lines = markdown.split(/\r?\n/);
120
+ for (let i = lines.length - 1; i >= 0; i--) {
121
+ const raw = lines[i];
122
+ if (raw.trim() === "") continue;
123
+ if (FENCE_LINE.test(raw)) continue; // opening/closing fence — structural
124
+ const stripped = stripBlockMarkers(raw);
125
+ if (stripped !== "") return stripped;
126
+ }
127
+ return "";
128
+ }
129
+
108
130
  /**
109
131
  * Strip common block-level markdown markers (`#`, `>`, `-`, `*`, `1.`)
110
132
  * so the collapsed line reads like prose. Runs a few passes for nesting
@@ -128,12 +150,13 @@ export function stripBlockMarkers(raw: string): string {
128
150
 
129
151
  /**
130
152
  * Collapse a thinking markdown block into a single line:
131
- * take its last non-empty line, strip block markers, and tail-truncate
153
+ * take its last meaningful line, strip block markers, and tail-truncate
132
154
  * to `availableWidth` display columns. Falls back to the original
133
155
  * markdown when there is nothing to show.
134
156
  */
135
157
  export function collapseThinking(markdown: string, availableWidth: number): string {
136
- const line = stripBlockMarkers(lastNonEmptyLine(markdown));
158
+ if (lastNonEmptyLine(markdown) === "") return markdown; // nothing visible
159
+ const line = lastMeaningfulLine(markdown);
137
160
  if (!line) return markdown;
138
161
  return tailFit(line, sanitizeWidth(availableWidth));
139
162
  }
@@ -256,19 +279,22 @@ export function countLineDiff(
256
279
  * (supports the legacy single oldText/newText shape). Returns undefined when
257
280
  * there is nothing renderable.
258
281
  */
259
- export function countEditsLineDiff(
260
- input: { edits?: unknown; oldText?: unknown; newText?: unknown } | undefined,
261
- ): LineDiffStat | undefined {
282
+ export function countEditsLineDiff(input: unknown): LineDiffStat | undefined {
262
283
  if (!input || typeof input !== "object") return undefined;
263
- const edits = Array.isArray(input.edits)
264
- ? input.edits
265
- : typeof input.oldText === "string" && typeof input.newText === "string"
266
- ? [{ oldText: input.oldText, newText: input.newText }]
284
+ const { edits, oldText, newText } = input as {
285
+ edits?: unknown;
286
+ oldText?: unknown;
287
+ newText?: unknown;
288
+ };
289
+ const editList = Array.isArray(edits)
290
+ ? edits
291
+ : typeof oldText === "string" && typeof newText === "string"
292
+ ? [{ oldText, newText }]
267
293
  : [];
268
294
  let added = 0;
269
295
  let removed = 0;
270
296
  let seen = false;
271
- for (const edit of edits as Array<{ oldText?: unknown; newText?: unknown }>) {
297
+ for (const edit of editList as Array<{ oldText?: unknown; newText?: unknown }>) {
272
298
  if (!edit || typeof edit !== "object") continue;
273
299
  const oldText = typeof edit.oldText === "string" ? edit.oldText : undefined;
274
300
  const newText = typeof edit.newText === "string" ? edit.newText : undefined;
@@ -298,8 +324,16 @@ export function liveThinkingLine(
298
324
  elapsedMs: number | undefined,
299
325
  width: number,
300
326
  ): string {
301
- const tail = stripBlockMarkers(lastNonEmptyLine(markdown));
302
- if (!tail) return markdown;
327
+ if (lastNonEmptyLine(markdown) === "") return markdown; // nothing visible
328
+ const tail = lastMeaningfulLine(markdown);
329
+ if (!tail) {
330
+ // Only structural lines so far (e.g. a bare code fence being streamed):
331
+ // show the label alone — falling back to the full text would flash the
332
+ // whole block open for a frame (the fold "flicker").
333
+ return elapsedMs === undefined
334
+ ? ""
335
+ : `**Thinking… (${formatDuration(elapsedMs, "live")})**`;
336
+ }
303
337
  const fitted = tailFit(tail, sanitizeWidth(width));
304
338
  if (elapsedMs === undefined) return fitted;
305
339
  return `**Thinking… (${formatDuration(elapsedMs, "live")})**\n\n${fitted}`;
@@ -318,8 +352,15 @@ export function foldedThinkingLine(
318
352
  ): string {
319
353
  const w = sanitizeWidth(width);
320
354
  if (style === "tail") {
321
- const tail = stripBlockMarkers(lastNonEmptyLine(markdown));
322
- if (!tail) return markdown;
355
+ if (lastNonEmptyLine(markdown) === "") return markdown; // nothing visible
356
+ const tail = lastMeaningfulLine(markdown);
357
+ if (!tail) {
358
+ // Nothing textual (e.g. thinking ends right at a code fence): the
359
+ // duration line alone — never fall back to the full markdown.
360
+ return ms === undefined
361
+ ? "**Thought…**"
362
+ : `**Thought for ${formatDuration(ms, "final")}**`;
363
+ }
323
364
  const prefix = ms === undefined ? "" : `**${formatDuration(ms, "final")}** · `;
324
365
  return tailFit(prefix + tail, w);
325
366
  }
@@ -347,3 +388,28 @@ export function expandedThinkingSuffix(ms: number | undefined): string {
347
388
  return ms === undefined ? "" : `\n\n**Thought for ${formatDuration(ms, "final")}**`;
348
389
  }
349
390
 
391
+ /**
392
+ * Close a trailing unclosed fenced code block (``` or ~~~) so text appended
393
+ * after `markdown` — e.g. the expanded-view footer — renders BELOW the code
394
+ * block instead of inside it as literal characters (visible `**` asterisks).
395
+ * A closing fence must match the opening fence's character, be at least as
396
+ * long, and carry no info string. Balanced input passes through unchanged.
397
+ */
398
+ export function closeOpenFences(markdown: string): string {
399
+ let fence: string | null = null;
400
+ for (const line of markdown.split(/\r?\n/)) {
401
+ const match = /^\s{0,3}(`{3,}|~{3,})(.*)$/.exec(line);
402
+ if (!match) continue;
403
+ if (fence === null) {
404
+ fence = match[1];
405
+ } else if (
406
+ match[1][0] === fence[0] &&
407
+ match[1].length >= fence.length &&
408
+ match[2].trim() === ""
409
+ ) {
410
+ fence = null;
411
+ }
412
+ }
413
+ return fence === null ? markdown : `${markdown}\n${fence}`;
414
+ }
415
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clapecho233/pi-smart-fold",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "pi coding-agent extension: collapse tool output at startup and fold thinking blocks to a live last line",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,10 +16,18 @@
16
16
  ]
17
17
  },
18
18
  "scripts": {
19
+ "postinstall": "node scripts/link-pi.mjs",
20
+ "link:pi": "node scripts/link-pi.mjs --required",
21
+ "typecheck": "tsc -p tsconfig.json",
22
+ "check": "node scripts/link-pi.mjs && tsc -p tsconfig.json && node scripts/load-check.mjs && node test/fold.test.mjs && node test/click-sim.mjs",
19
23
  "test": "node test/fold.test.mjs",
20
24
  "test:sim": "node test/click-sim.mjs"
21
25
  },
22
26
  "engines": {
23
27
  "node": ">=22.18"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^24.13.6",
31
+ "typescript": "5.9"
24
32
  }
25
33
  }
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Link the locally installed pi runtime into this project's node_modules so
4
+ * the extension typechecks and imports against the exact pi version that is
5
+ * installed on this machine (the same runtime that loads the extension).
6
+ *
7
+ * Creates:
8
+ * node_modules/@earendil-works/pi-coding-agent -> <pi>/libexec/lib/node_modules/@earendil-works/pi-coding-agent
9
+ * node_modules/@earendil-works/pi-tui -> <pi>/.../pi-coding-agent/node_modules/@earendil-works/pi-tui
10
+ *
11
+ * Re-run after every pi upgrade (also wired up as the npm `postinstall` and
12
+ * `link:pi` scripts; pass --required to fail hard when pi is not found).
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ import { mkdirSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
16
+ import { dirname, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
20
+
21
+ /** Resolve the pi installation root from the `pi` launcher on PATH. */
22
+ function piRoot() {
23
+ for (const finder of [
24
+ () => process.env.PI_BIN,
25
+ () => execFileSync("which", ["pi"], { encoding: "utf8" }).trim(),
26
+ ]) {
27
+ try {
28
+ const bin = finder();
29
+ if (!bin) continue;
30
+ // /opt/homebrew/bin/pi -> ../Cellar/pi-coding-agent/<ver>/bin/pi
31
+ const real = execFileSync("realpath", [bin], { encoding: "utf8" }).trim();
32
+ return dirname(dirname(real)); // .../pi-coding-agent/<ver>
33
+ } catch {
34
+ // try next strategy
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ const root = piRoot();
41
+ if (!root) {
42
+ const message = "link-pi: could not locate the pi installation (is `pi` on PATH?)";
43
+ if (process.argv.includes("--required")) {
44
+ console.error(message);
45
+ process.exit(1);
46
+ }
47
+ console.warn(`${message} — skipping (dev typecheck will not work until linked)`);
48
+ process.exit(0);
49
+ }
50
+
51
+ const agentPkg = resolve(root, "libexec/lib/node_modules/@earendil-works/pi-coding-agent");
52
+ const tuiPkg = resolve(agentPkg, "node_modules/@earendil-works/pi-tui");
53
+
54
+ const links = [
55
+ ["@earendil-works/pi-coding-agent", agentPkg],
56
+ ["@earendil-works/pi-tui", tuiPkg],
57
+ ];
58
+
59
+ for (const [name, target] of links) {
60
+ const linkPath = resolve(projectRoot, "node_modules", name);
61
+ try {
62
+ const existing = readlinkSync(linkPath);
63
+ if (existing === target) {
64
+ console.log(`link-pi: ${name} already -> ${target}`);
65
+ continue;
66
+ }
67
+ rmSync(linkPath, { force: true, recursive: true });
68
+ } catch {
69
+ // no existing link (or a real directory — leave real dirs alone unless
70
+ // they are dangling symlinks, handled by rmSync above)
71
+ }
72
+ mkdirSync(dirname(linkPath), { recursive: true });
73
+ symlinkSync(target, linkPath);
74
+ console.log(`link-pi: linked ${name} -> ${target}`);
75
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Load-test the extension against the installed pi runtime (jiti), the same
3
+ * loader pi uses for filesystem extensions. Exercises the default export
4
+ * with a stub ExtensionAPI and reports what got registered.
5
+ */
6
+ import { createRequire } from "node:module";
7
+ import { resolve } from "node:path";
8
+
9
+ const root = resolve(import.meta.dirname, "..");
10
+ // Resolve jiti from the linked pi package (extensions run inside pi's module
11
+ // root, so this mirrors the real resolution environment).
12
+ const require2 = createRequire(
13
+ resolve(root, "node_modules/@earendil-works/pi-coding-agent/package.json"),
14
+ );
15
+ const jitiPath = require2.resolve("jiti");
16
+ const { createJiti } = await import(
17
+ jitiPath.startsWith("/") ? `file://${jitiPath}` : jitiPath,
18
+ );
19
+ const jiti = createJiti(import.meta.url);
20
+ const mod = await jiti.import(resolve(root, "index.ts"), { default: true });
21
+
22
+ console.log("default export:", typeof mod);
23
+ if (typeof mod !== "function") process.exit(1);
24
+
25
+ const registered = { transformers: 0, tools: [], commands: [], handlers: [] };
26
+ const stub = {
27
+ registerMarkdownTransformer: () => registered.transformers++,
28
+ registerTool: (t) => registered.tools.push(t?.name),
29
+ registerCommand: (n) => registered.commands.push(n),
30
+ on: (e) => registered.handlers.push(e),
31
+ appendEntry: () => {},
32
+ };
33
+ mod(stub);
34
+ console.log(
35
+ "transformers:", registered.transformers,
36
+ "| tools:", registered.tools.join(","),
37
+ "| commands:", registered.commands.join(","),
38
+ "| handlers:", registered.handlers.join(","),
39
+ );
40
+ if (registered.transformers !== 1 || registered.tools.length !== 7 || registered.commands[0] !== "fold") {
41
+ console.error("registration shape unexpected");
42
+ process.exit(1);
43
+ }
44
+ console.log("OK");
@@ -7,7 +7,9 @@
7
7
  * streaming: tail →(click)→ full →(click)→ tail →(click)→ full …
8
8
  * full view keeps the ticking `Thinking… (Ns)` line at the
9
9
  * BOTTOM of the block; the hidden-label middle state never
10
- * appears.
10
+ * appears. Same one-click toggle for a run that already
11
+ * finished while the message keeps streaming (folded duration
12
+ * line ↔ full text with footer).
11
13
  * finalized: seeded hidden `Thought for …` label →(click)→ full+footer
12
14
  * →(click)→ label …
13
15
  *
@@ -205,20 +207,208 @@ compB.updateContent(compB.lastMessage);
205
207
  assert.equal(compB.thinkingVisibilityOverrides.get(1), false, "live-run click redirected");
206
208
  assert.match(render("second run", true), /^second run\n\n\*\*Thinking… /, "live run expanded");
207
209
 
208
- // clicking the finished run (hidden toggle) must not collapse the live run
209
- compB.thinkingVisibilityOverrides.set(0, true);
210
+ // clicking the finished run while the message still streams: ONE click
211
+ // expands it directly — the global `Thought…` hidden-label middle state
212
+ // must never appear (this was the reported bug)
213
+ compB.thinkingVisibilityOverrides.set(0, true); // pi click handler for run 0
210
214
  compB.updateContent(compB.lastMessage);
211
- assert.equal(compB.thinkingVisibilityOverrides.get(0), true, "finished run toggles natively");
215
+ assert.equal(compB.thinkingVisibilityOverrides.get(0), false, "finished-run click redirected to visible");
216
+ assert.match(
217
+ render("first run", true),
218
+ /^first run\n\n\*\*Thought for /,
219
+ "one click expands the finished run with the footer",
220
+ );
212
221
  assert.match(
213
222
  render("second run", true),
214
223
  /^second run\n\n\*\*Thinking… /,
215
224
  "finished-run click does not clobber the live run's open state",
216
225
  );
217
226
 
218
- // clicking it back open reveals the finished run, live run still untouched
219
- compB.thinkingVisibilityOverrides.set(0, false);
227
+ // clicking it again folds the finished run back to the duration line
228
+ compB.thinkingVisibilityOverrides.set(0, true);
220
229
  compB.updateContent(compB.lastMessage);
221
- assert.match(render("first run", true), /^first run\n\n\*\*Thought for /, "finished run expands with footer");
222
- assert.match(render("second run", true), /^second run\n\n\*\*Thinking… /, "live run still expanded");
230
+ assert.equal(compB.thinkingVisibilityOverrides.get(0), false, "still visible after collapse");
231
+ assert.match(
232
+ render("first run", true),
233
+ /^\*\*Thought for /,
234
+ "second click re-folds the finished run (no label middle state)",
235
+ );
236
+ assert.match(
237
+ render("second run", true),
238
+ /^second run\n\n\*\*Thinking… /,
239
+ "live run still expanded",
240
+ );
241
+
242
+ // third click expands again — a clean two-state toggle
243
+ compB.thinkingVisibilityOverrides.set(0, true);
244
+ compB.updateContent(compB.lastMessage);
245
+ assert.match(
246
+ render("first run", true),
247
+ /^first run\n\n\*\*Thought for /,
248
+ "third click expands again",
249
+ );
223
250
 
224
251
  console.log("✓ scenario B: finished-run clicks leave the live run's toggle intact");
252
+
253
+ // ---- scenario C: the reported regression — single run finished, answer
254
+ // text still streaming. `Thought for …` → click → full text, click → folded
255
+ // again. Never the global `Thought…` label. --------------------------------
256
+ const fireC = fire; // same handlers
257
+ const msgC = (thinking, text) => ({
258
+ content: [
259
+ { type: "thinking", thinking },
260
+ { type: "text", text },
261
+ ],
262
+ stopReason: undefined,
263
+ });
264
+ const thinkOnly = (t) => ({ content: [{ type: "thinking", thinking: t }], stopReason: undefined });
265
+ await fireC("message_update", {
266
+ message: { role: "assistant" },
267
+ assistantMessageEvent: { type: "thinking_start", contentIndex: 0, partial: thinkOnly("") },
268
+ });
269
+ await fireC("message_update", {
270
+ message: { role: "assistant" },
271
+ assistantMessageEvent: {
272
+ type: "thinking_delta",
273
+ contentIndex: 0,
274
+ delta: "plan the answer",
275
+ partial: thinkOnly("plan the answer"),
276
+ },
277
+ });
278
+ await fireC("message_update", {
279
+ message: { role: "assistant" },
280
+ assistantMessageEvent: { type: "thinking_end", contentIndex: 0, partial: thinkOnly("plan the answer") },
281
+ });
282
+ // the following text block closes the thinking run: it now has a finalized
283
+ // duration while the message itself is still streaming
284
+ await fireC("message_update", {
285
+ message: { role: "assistant" },
286
+ assistantMessageEvent: {
287
+ type: "text_start",
288
+ contentIndex: 1,
289
+ partial: msgC("plan the answer", ""),
290
+ },
291
+ });
292
+
293
+ const compC = new AssistantMessageComponent();
294
+ compC.updateContent(msgC("plan the answer", "answering…"), true);
295
+ assert.match(
296
+ render("plan the answer", true),
297
+ /^\*\*Thought for /,
298
+ "finished run folds while the answer still streams",
299
+ );
300
+
301
+ // click 1 → full text + footer (NOT the global `Thought…` label)
302
+ compC.thinkingVisibilityOverrides.set(0, true);
303
+ compC.updateContent(compC.lastMessage);
304
+ assert.equal(compC.thinkingVisibilityOverrides.get(0), false, "click redirected to visible");
305
+ assert.match(
306
+ render("plan the answer", true),
307
+ /^plan the answer\n\n\*\*Thought for /,
308
+ "one click expands to full text with the footer",
309
+ );
310
+
311
+ // click 2 → folded duration line again
312
+ compC.thinkingVisibilityOverrides.set(0, true);
313
+ compC.updateContent(compC.lastMessage);
314
+ assert.equal(compC.thinkingVisibilityOverrides.get(0), false, "still visible after collapse");
315
+ assert.match(
316
+ render("plan the answer", true),
317
+ /^\*\*Thought for /,
318
+ "second click re-folds to the duration line",
319
+ );
320
+
321
+ // message ends: the run left folded normalizes into the seeded hidden state
322
+ // (label with duration) so the next click expands directly
323
+ await fireC("message_end", {
324
+ message: { role: "assistant", ...msgC("plan the answer", "answering…"), stopReason: "stop" },
325
+ });
326
+ compC.updateContent(compC.lastMessage, false);
327
+ assert.equal(compC.thinkingVisibilityOverrides.get(0), true, "collapsed run normalized to seeded hidden");
328
+ assert.match(compC.hiddenThinkingLabel, /Thought for /, "label carries the duration");
329
+ compC.thinkingVisibilityOverrides.set(0, false);
330
+ compC.updateContent(compC.lastMessage);
331
+ assert.match(
332
+ render("plan the answer", false),
333
+ /^plan the answer\n\n\*\*Thought for /,
334
+ "click after finalize expands in one step",
335
+ );
336
+
337
+ console.log("✓ scenario C: finished run in a streaming message toggles with one click");
338
+
339
+ // ---- scenario D: code blocks inside thinking -------------------------------
340
+ // Collapsed: when the last non-empty line is a bare code fence, the fold must
341
+ // NOT fall back to the full markdown (that flash-open was the flicker).
342
+ // Expanded: the footer must render OUTSIDE an unclosed fence — bold text, not
343
+ // literal `**` asterisks (verified through pi's real Markdown renderer).
344
+ import { Markdown } from "@earendil-works/pi-tui";
345
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
346
+
347
+ const fireD = fire; // same handlers
348
+ const md = (t) => ({ content: [{ type: "thinking", thinking: t }], stopReason: undefined });
349
+ const streamD = (delta, partial) =>
350
+ fireD("message_update", {
351
+ message: { role: "assistant" },
352
+ assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta, partial: md(partial) },
353
+ });
354
+
355
+ // stream up to the opening fence: last non-empty line is the bare-ish fence
356
+ await fireD("message_update", {
357
+ message: { role: "assistant" },
358
+ assistantMessageEvent: { type: "thinking_start", contentIndex: 0, partial: md("") },
359
+ });
360
+ await streamD("consider the snippet\n```ts", "consider the snippet\n```ts");
361
+ const compD = new AssistantMessageComponent();
362
+ compD.updateContent(md("consider the snippet\n```ts"), true);
363
+ assert.match(
364
+ render("consider the snippet\n```ts", true),
365
+ /^\*\*Thinking… \(\d+s\)\*\*\n\nconsider the snippet$/,
366
+ "bare fence as last line does not unfold the tail view",
367
+ );
368
+
369
+ // code content streams in: it becomes the tail line
370
+ await streamD("\nconst x = 1;", "consider the snippet\n```ts\nconst x = 1;");
371
+ compD.updateContent(md("consider the snippet\n```ts\nconst x = 1;"), true);
372
+ assert.match(
373
+ render("consider the snippet\n```ts\nconst x = 1;", true),
374
+ /^\*\*Thinking… \(\d+s\)\*\*\n\nconst x = 1;$/,
375
+ "code content line shows as the tail",
376
+ );
377
+
378
+ // expand while the fence is still unclosed → fence closed, footer outside it
379
+ click(compD, 0);
380
+ const expandedD = render("consider the snippet\n```ts\nconst x = 1;", true);
381
+ assert.match(
382
+ expandedD,
383
+ /```ts\nconst x = 1;\n```\n\n\*\*Thinking… \(\d+s\)\*\*$/,
384
+ "unclosed fence gets closed and the footer lands below it",
385
+ );
386
+
387
+ // render through pi's REAL Markdown component: the footer line must be bold
388
+ // "Thinking… (Ns)" — no literal asterisks from being swallowed by the fence
389
+ const stripAnsi = (s) => s.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, "");
390
+ const renderedD = new Markdown(expandedD, 1, 0, getMarkdownTheme()).render(80).map(stripAnsi);
391
+ const lastLineD = [...renderedD].reverse().find((l) => l.trim() !== "");
392
+ assert.match(lastLineD.trim(), /^Thinking… \(\d+s\)$/, "footer renders bold without literal asterisks");
393
+ assert.equal(
394
+ renderedD.some((l) => l.includes("**Thinking")),
395
+ false,
396
+ "no raw ** markers leak into the render",
397
+ );
398
+ assert.equal(
399
+ renderedD.some((l) => l.includes("const x = 1;")),
400
+ true,
401
+ "code content still rendered",
402
+ );
403
+
404
+ // and the pre-fix behavior really did swallow the footer: sanity-check that
405
+ // an unclosed fence without our close would show literal asterisks
406
+ const unclosedView = "consider the snippet\n```ts\nconst x = 1;\n\n**Thinking… (8s)**";
407
+ const renderedU = new Markdown(unclosedView, 1, 0, getMarkdownTheme()).render(80).map(stripAnsi);
408
+ assert.equal(
409
+ renderedU.some((l) => l.includes("**Thinking…")),
410
+ true,
411
+ "sanity: unclosed fence would have swallowed the footer (regression guard)",
412
+ );
413
+
414
+ console.log("✓ scenario D: code blocks — no fold flicker, footer renders outside the fence");
@@ -12,8 +12,10 @@ import {
12
12
  displayWidth,
13
13
  tailFit,
14
14
  lastNonEmptyLine,
15
+ lastMeaningfulLine,
15
16
  stripBlockMarkers,
16
17
  collapseThinking,
18
+ closeOpenFences,
17
19
  formatDuration,
18
20
  hashText,
19
21
  countLineDiff,
@@ -100,6 +102,22 @@ check("lastNonEmptyLine: all empty → empty string", () => {
100
102
  assert.equal(lastNonEmptyLine("\n \n"), "");
101
103
  });
102
104
 
105
+ // ---------------------------------------------------- lastMeaningfulLine ----
106
+ check("lastMeaningfulLine: picks last strippable non-empty line", () => {
107
+ assert.equal(lastMeaningfulLine("first\nsecond\n\n"), "second");
108
+ assert.equal(lastMeaningfulLine("plan:\n- [ ] do it", ), "[ ] do it");
109
+ });
110
+ check("lastMeaningfulLine: skips bare code fences (no fold flicker)", () => {
111
+ assert.equal(lastMeaningfulLine("thinking text\n```"), "thinking text");
112
+ assert.equal(lastMeaningfulLine("```js\ncode()\n```"), "code()");
113
+ assert.equal(lastMeaningfulLine("~~~\nquoted\n~~~"), "quoted");
114
+ });
115
+ check("lastMeaningfulLine: only structural lines → empty", () => {
116
+ assert.equal(lastMeaningfulLine("```"), "");
117
+ assert.equal(lastMeaningfulLine("```js"), "");
118
+ assert.equal(lastMeaningfulLine("\n \n"), "");
119
+ });
120
+
103
121
  // ---------------------------------------------------- stripBlockMarkers ----
104
122
  check("stripBlockMarkers: heading", () => {
105
123
  assert.equal(stripBlockMarkers("## Heading"), "Heading");
@@ -269,6 +287,23 @@ check("liveThinkingLine: truncates tail to width", () => {
269
287
  check("liveThinkingLine: empty content passes through", () => {
270
288
  assert.equal(liveThinkingLine("\n \n", 1_000, 80), "\n \n");
271
289
  });
290
+ check("liveThinkingLine: bare fence last → label only, never the full text", () => {
291
+ // While a code block streams, the last non-empty line is often the bare
292
+ // fence — the fold must not flash open (the old fallback returned the full
293
+ // markdown, causing the flicker).
294
+ assert.equal(
295
+ liveThinkingLine("thought about code\n```", 8_000, 80),
296
+ "**Thinking… (8s)**\n\nthought about code",
297
+ );
298
+ assert.equal(liveThinkingLine("```", 8_000, 80), "**Thinking… (8s)**");
299
+ assert.equal(liveThinkingLine("```", undefined, 80), "");
300
+ });
301
+ check("liveThinkingLine: fence with info string is structural", () => {
302
+ assert.equal(
303
+ liveThinkingLine("before block\n```python", 3_000, 80),
304
+ "**Thinking… (3s)**\n\nbefore block",
305
+ );
306
+ });
272
307
  check("foldedThinkingLine: smart shows bold Thought-for line", () => {
273
308
  assert.equal(
274
309
  foldedThinkingLine("anything at all", 12_340, 80, "smart"),
@@ -284,6 +319,16 @@ check("foldedThinkingLine: tail keeps the tail + bold duration", () => {
284
319
  "**12.3s** · last line",
285
320
  );
286
321
  });
322
+ check("foldedThinkingLine: tail ending at a fence folds to the code line", () => {
323
+ // Thinking that ends with a code block ends with the closing fence —
324
+ // the fold must show the last code line, not unfold the whole block.
325
+ assert.equal(
326
+ foldedThinkingLine("intro\n```js\ncode()\n```", 12_340, 80, "tail"),
327
+ "**12.3s** · code()",
328
+ );
329
+ assert.equal(foldedThinkingLine("```", 12_340, 80, "tail"), "**Thought for 12.3s**");
330
+ assert.equal(foldedThinkingLine("```", undefined, 80, "tail"), "**Thought…**");
331
+ });
287
332
  check("expandedThinkingSuffix: bold footer or empty", () => {
288
333
  assert.equal(expandedThinkingSuffix(61_500), "\n\n**Thought for 1m01s**");
289
334
  assert.equal(expandedThinkingSuffix(undefined), "");
@@ -297,6 +342,31 @@ check("liveExpandedSuffix: bold ticking footer at the bottom", () => {
297
342
  assert.equal(view.split("\n").at(-1), "**Thinking… (8s)**");
298
343
  });
299
344
 
345
+ // ---------------------------------------------------------- closeOpenFences ----
346
+ check("closeOpenFences: balanced fences pass through unchanged", () => {
347
+ assert.equal(closeOpenFences("text\n```js\ncode()\n```"), "text\n```js\ncode()\n```");
348
+ assert.equal(closeOpenFences("plain thinking"), "plain thinking");
349
+ assert.equal(closeOpenFences(""), "");
350
+ });
351
+ check("closeOpenFences: unclosed fence gets a matching close", () => {
352
+ assert.equal(closeOpenFences("text\n```js\ncode("), "text\n```js\ncode(\n```");
353
+ // longer opening fence needs an equally long close
354
+ assert.equal(closeOpenFences("````\ncode"), "````\ncode\n````");
355
+ });
356
+ check("closeOpenFences: tilde fences tracked separately", () => {
357
+ assert.equal(closeOpenFences("~~~\nquoted"), "~~~\nquoted\n~~~");
358
+ // a ``` line does not close a ~~~ fence (it is content), and vice versa
359
+ assert.equal(closeOpenFences("~~~\n```\nq"), "~~~\n```\nq\n~~~");
360
+ });
361
+ check("closeOpenFences: closing fence with info string does not close", () => {
362
+ assert.equal(closeOpenFences("```\ncode\n```js"), "```\ncode\n```js\n```");
363
+ });
364
+ check("closeOpenFences: footer appended after the close renders as bold", () => {
365
+ const view = closeOpenFences("thinking\n```js\ncode(") + "\n\n**Thinking… (8s)**";
366
+ assert.equal(view.split("\n").at(-1), "**Thinking… (8s)**");
367
+ assert.match(view, /```js\ncode\(\n```\n\n\*\*Thinking/);
368
+ });
369
+
300
370
  // ------------------------------------------------------------ config ----
301
371
  check("config: defaults when file missing", () => {
302
372
  const dir = mkdtempSync(join(tmpdir(), "smart-fold-"));
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "allowImportingTsExtensions": true,
9
+ "skipLibCheck": true,
10
+ "noUncheckedIndexedAccess": false,
11
+ "types": ["node"]
12
+ },
13
+ "include": ["index.ts", "lib/**/*.ts"]
14
+ }