@clapecho233/pi-smart-fold 0.1.1 → 0.2.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 +85 -61
- package/index.ts +110 -34
- package/lib/fold.ts +80 -14
- package/package.json +9 -1
- package/scripts/link-pi.mjs +75 -0
- package/scripts/load-check.mjs +44 -0
- package/test/click-sim.mjs +198 -8
- package/test/fold.test.mjs +70 -0
- package/tsconfig.json +14 -0
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
|
-
|
|
9
|
+
## Features
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
### 1. Thinking fold + live timer (`smart` mode, default)
|
|
10
12
|
|
|
11
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
### 2. Tool output folding
|
|
33
38
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
84
|
+
# Option B: install via the pi package manager
|
|
78
85
|
pi install git:<repo-url>
|
|
79
86
|
|
|
80
|
-
#
|
|
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
|
-
>
|
|
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
|
|
93
|
-
npm run
|
|
94
|
-
|
|
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
|
|
101
|
-
lib/fold.ts
|
|
102
|
-
lib/thinking.ts
|
|
103
|
-
lib/config.ts
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
111
|
-
- 展开状态为运行时状态,新会话/重载后恢复折叠;窗口缩放不会收起你已展开的块。
|
|
112
|
-
- write 增删统计只对当前会话中的写入生效(恢复的旧会话没有执行前快照可对比)。
|
|
113
|
-
- 与 pi 原生 "Hide thinking blocks"(`ctrl+t` 切换)叠加时,若隐藏了思考块,思考中不会显示滚动行 —— 建议保持默认的显示状态,由本插件负责折叠。
|
|
132
|
+
## Compatibility
|
|
114
133
|
|
|
115
|
-
|
|
134
|
+
Built and verified against pi **`0.86.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
|
-
|
|
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
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
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
|
|
145
|
-
// just toggled that run to the hidden-label state; we flip
|
|
146
|
-
// straight back to visible and toggle our own
|
|
147
|
-
//
|
|
148
|
-
// (timing line pinned at the bottom)
|
|
149
|
-
//
|
|
150
|
-
//
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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");
|
package/test/click-sim.mjs
CHANGED
|
@@ -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
|
|
209
|
-
|
|
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),
|
|
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
|
|
219
|
-
compB.thinkingVisibilityOverrides.set(0,
|
|
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.
|
|
222
|
-
assert.match(
|
|
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");
|
package/test/fold.test.mjs
CHANGED
|
@@ -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
|
+
}
|