@aiwayds/pi-think-panel 0.2.0 → 0.3.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 +83 -138
- package/extensions/pi-think-panel.ts +173 -568
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
# pi-think-panel
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Thinking viewport widget for the pi coding agent TUI — a todo-style widget
|
|
4
|
+
above the editor that live-scrolls the model's reasoning.
|
|
4
5
|
|
|
5
|
-
>
|
|
6
|
-
|
|
7
|
-
## Demo / 演示
|
|
8
|
-
|
|
9
|
-

|
|
6
|
+
> pi 编码代理 TUI 的思考视口 widget——像 todo widget 一样挂在输入框上方,
|
|
7
|
+
> 实时滚动显示模型推理内容。
|
|
10
8
|
|
|
11
9
|
## Why / 为什么做这个扩展
|
|
12
10
|
|
|
@@ -19,181 +17,128 @@ and both ways of surfacing that output are bad:
|
|
|
19
17
|
- **Fully shown**: a wall of reasoning text floods the chat pane, pushing the
|
|
20
18
|
real content around and making the conversation hard to read.
|
|
21
19
|
|
|
22
|
-
This extension sits in between. A
|
|
23
|
-
|
|
24
|
-
alive and where its head is at —
|
|
25
|
-
when you actually want to read the details. When thinking stops, the panel
|
|
26
|
-
quietly hides itself again.
|
|
20
|
+
This extension sits in between. A one-to-seven-line viewport sits right above
|
|
21
|
+
the editor and live-scrolls the **latest** reasoning — a heartbeat that shows
|
|
22
|
+
the model is alive and where its head is at — while the chat pane stays clean.
|
|
27
23
|
|
|
28
24
|
> **中文**:推理模型的思考时间越来越长,而两种展示方式都不理想——
|
|
29
|
-
> 隐藏(`hideThinkingBlock` 开启或滚动出视野)时 TUI
|
|
30
|
-
>
|
|
31
|
-
>
|
|
32
|
-
>
|
|
33
|
-
> 思路在哪),需要时 `ctrl+o` 展开全文详情;思考结束面板自动隐藏。
|
|
25
|
+
> 隐藏(`hideThinkingBlock` 开启或滚动出视野)时 TUI 看起来像卡住,缺乏健康
|
|
26
|
+
> 监控和进度信号;全部显示时,大段推理文本冲击聊天区,对话难以阅读。
|
|
27
|
+
> 本扩展取中间态:输入框上方挂一个 1–7 行的滚动视口,始终跟随**最新**推理
|
|
28
|
+
> ("心跳",证明模型活着、思路在哪),聊天区保持干净。
|
|
34
29
|
|
|
35
30
|
## Layout / 布局
|
|
36
31
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
the newest content
|
|
32
|
+
The widget occupies the extension widget slot above the editor (same slot the
|
|
33
|
+
todo widget uses). It shows the latest N wrapped lines of thinking; when the
|
|
34
|
+
stream grows past N lines it scrolls up, so the newest content is always in
|
|
35
|
+
view. No thinking → zero rows, no space taken.
|
|
40
36
|
|
|
41
|
-
>
|
|
42
|
-
>
|
|
37
|
+
> widget 位于输入框上方的扩展 widget 槽位(与 todo widget 同槽位),显示最近
|
|
38
|
+
> N 行思考内容;超过 N 行向上滚动,最新内容始终可见。没有思考时 0 行不占位。
|
|
43
39
|
|
|
44
40
|
```text
|
|
45
|
-
|
|
46
|
-
│
|
|
47
|
-
│
|
|
48
|
-
|
|
49
|
-
│ │
|
|
50
|
-
│
|
|
51
|
-
|
|
52
|
-
│
|
|
53
|
-
|
|
54
|
-
│ editor │
|
|
55
|
-
└──────────────────────────────────────────────────────────────┘
|
|
41
|
+
┌──────────────────────────────────────────────────────────┐
|
|
42
|
+
│ chat history │
|
|
43
|
+
│ … │
|
|
44
|
+
├──────────────────────────────────────────────────────────┤
|
|
45
|
+
│ 🧠Thinking: 先确认 widget 槽位的宽度约束,再决定 wrap 策略 │ ← widget (N lines,
|
|
46
|
+
│ 终端宽度变化时按新宽度重排,始终显示窗口尾部最新内容 │ scrolls up)
|
|
47
|
+
├──────────────────────────────────────────────────────────┤
|
|
48
|
+
│ ❯ editor input │
|
|
49
|
+
└──────────────────────────────────────────────────────────┘
|
|
56
50
|
```
|
|
57
51
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
> 视图,始终滚动跟随最新内容。
|
|
63
|
-
|
|
64
|
-
```text
|
|
65
|
-
┌──────────────────────────────────────────────────────────────┐
|
|
66
|
-
│ │
|
|
67
|
-
│ ╭─ Thinking… ⌃O 收起 ─────────────────────────────────╮ │
|
|
68
|
-
│ │ …(earlier lines omitted) │ │
|
|
69
|
-
│ │ reasoning line │ │
|
|
70
|
-
│ │ reasoning line ← auto-scrolls with new content │ │
|
|
71
|
-
│ │ reasoning line │ │
|
|
72
|
-
│ ╰──────────────────────────────────────────────────────╯ │
|
|
73
|
-
│ │
|
|
74
|
-
│ editor │
|
|
75
|
-
└──────────────────────────────────────────────────────────────┘
|
|
76
|
-
```
|
|
52
|
+
- Default height is **1 line**: `🧠Thinking: <latest text>` — tail-truncated
|
|
53
|
+
to terminal width.
|
|
54
|
+
- Heights **3 / 5 / 7** show more of the latest reasoning; row 0 keeps the
|
|
55
|
+
`🧠Thinking:` prefix, rows below are pure continuation lines.
|
|
77
56
|
|
|
78
|
-
|
|
79
|
-
|
|
57
|
+
> 默认 **1 行**:`🧠Thinking: <最新思考>`,超出终端宽度尾部截断。
|
|
58
|
+
> **3 / 5 / 7 行**显示更多最新推理;首行保留 `🧠Thinking:` 前缀,其余为纯内容行。
|
|
80
59
|
|
|
81
|
-
|
|
82
|
-
> 让出右侧 sidebar 区域。
|
|
60
|
+
## Command / 命令
|
|
83
61
|
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
│ └────────────┘
|
|
90
|
-
│ chat / editor
|
|
91
|
-
└─────────────────────────────────────────────────────────────
|
|
62
|
+
```bash
|
|
63
|
+
/think-panel # show current height / 查看当前行数
|
|
64
|
+
/think-panel 3 # switch to 3 lines / 切换 3 行
|
|
65
|
+
/think-panel 7 # switch to 7 lines / 切换 7 行
|
|
66
|
+
/think-panel off # disable the widget (0 rows) / 关闭 widget
|
|
92
67
|
```
|
|
93
68
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
dependency, no error.
|
|
97
|
-
|
|
98
|
-
> 未安装 sidebar(`globalThis` 上没有 `__piSidebarLayout`)或终端过窄时,面板
|
|
99
|
-
> 直接使用全宽 —— 可独立运行,无依赖、不报错。
|
|
100
|
-
|
|
101
|
-
## Keys / 快捷键
|
|
102
|
-
|
|
103
|
-
| Key | Action |
|
|
104
|
-
| --- | --- |
|
|
105
|
-
| ctrl+o | Toggle between the small panel and the full-text overlay (thinking off → info notice) |
|
|
106
|
-
| ctrl+h | Toggle the small panel on/off (stays hidden until toggled back or the next thinking block starts) |
|
|
107
|
-
|
|
108
|
-
| 按键 | 行为 |
|
|
109
|
-
| --- | --- |
|
|
110
|
-
| ctrl+o | 小面板与大窗口(全文视图)之间切换(思考关闭时 → 提示信息) |
|
|
111
|
-
| ctrl+h | 切换小面板显示/隐藏(保持状态,直到再次切换或下一个思考块开始) |
|
|
69
|
+
The setting is persisted to `~/.pi/agent/think-panel.json` as
|
|
70
|
+
`{"lines": N}` (`0` = off) and survives restarts.
|
|
112
71
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
> 说明:ctrl+o 原本是工具面板(app.tools.expand)的保留键,本扩展接管了它 ——
|
|
116
|
-
> 如需保留可在 keybindings 配置里重新绑定。
|
|
72
|
+
> 设置持久化到 `~/.pi/agent/think-panel.json`(`{"lines": N}`,`0` = 关),
|
|
73
|
+
> 重启后保持。
|
|
117
74
|
|
|
118
75
|
## What it does / 功能
|
|
119
76
|
|
|
120
|
-
- Captures
|
|
121
|
-
-
|
|
122
|
-
|
|
123
|
-
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
> -
|
|
130
|
-
>
|
|
77
|
+
- Captures live thinking from `message_update` events and renders it in the
|
|
78
|
+
editor-adjacent widget slot — no overlays, no key grabbing. `ctrl+o`
|
|
79
|
+
(expand tool output) and `ctrl+h` remain pi-native.
|
|
80
|
+
- Lines are **visual lines** after ANSI-aware wrapping to the current terminal
|
|
81
|
+
width — the view reflows on resize.
|
|
82
|
+
- Shows while the model is thinking; the viewport clears when the turn
|
|
83
|
+
settles (`agent_settled`) or thinking is switched off.
|
|
84
|
+
- Completed think blocks within a turn are separated by a blank line.
|
|
85
|
+
|
|
86
|
+
> - 从 `message_update` 事件捕获实时思考,渲染在输入框上方 widget 槽位——
|
|
87
|
+
> 无浮层、不抢键。`ctrl+o`(展开工具输出)与 `ctrl+h` 保持 pi 原生行为。
|
|
88
|
+
> - 行 = 按当前终端宽度 ANSI 感知换行后的**视觉行**,终端缩放自动重排。
|
|
89
|
+
> - 模型思考期间显示;回合结束(`agent_settled`)或思考关闭后清空。
|
|
90
|
+
> - 同一回合内多个思考块之间用空行分隔。
|
|
131
91
|
|
|
132
92
|
## Config / 配置
|
|
133
93
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
> 修改文件顶部的常量,然后 `/reload` 生效:
|
|
94
|
+
`~/.pi/agent/think-panel.json`:
|
|
137
95
|
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
// "last": panel stays visible with the last think text.
|
|
141
|
-
const EMPTY_THINK_MODE: "last" | "hide" = "hide";
|
|
96
|
+
```json
|
|
97
|
+
{ "lines": 1 }
|
|
142
98
|
```
|
|
143
99
|
|
|
144
|
-
|
|
100
|
+
Valid values: `1 | 3 | 5 | 7 | 0` (`0` = off). Prefer the `/think-panel`
|
|
101
|
+
command — it validates and writes the same file.
|
|
145
102
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
```bash
|
|
149
|
-
export PI_THINK_PANEL_CLOSE_DELAY_MS=5000 # hide 5s after thinking ends (default 1000)
|
|
150
|
-
export PI_THINK_PANEL_BG=toolSuccessBg # panel background (theme bg color key)
|
|
151
|
-
export PI_THINK_PANEL_MAX_LINES=5 # small-panel line count (default 5)
|
|
152
|
-
export PI_THINK_PANEL_MIN_TERM_ROWS=12 # hide small panel below this terminal height (default MAX_LINES+6)
|
|
153
|
-
```
|
|
103
|
+
> 合法值 `1 | 3 | 5 | 7 | 0`(`0` = 关)。推荐用 `/think-panel` 命令切换,
|
|
104
|
+
> 会校验参数并写同一文件。
|
|
154
105
|
|
|
155
|
-
|
|
156
|
-
panel shows (positive integer, default 5).
|
|
157
|
-
|
|
158
|
-
`PI_THINK_PANEL_MIN_TERM_ROWS` is the minimum terminal height (rows) for the
|
|
159
|
-
small panel: below it the panel hides automatically (and reappears when the
|
|
160
|
-
terminal grows again) — handy when the window is dragged short or the font is
|
|
161
|
-
zoomed in. Default is MAX_LINES + 6.
|
|
106
|
+
## Install / update / 安装与更新
|
|
162
107
|
|
|
163
|
-
|
|
164
|
-
>
|
|
165
|
-
> `PI_THINK_PANEL_MIN_TERM_ROWS` 是小面板可见所需的最小终端高度(行数):低于
|
|
166
|
-
> 它面板自动隐藏(终端恢复后自动重现)——窗口拖矮或字体放大时很有用。默认
|
|
167
|
-
> MAX_LINES + 6。
|
|
108
|
+
From npm (recommended / 推荐):
|
|
168
109
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
`/reload`), then `/reload` to apply.
|
|
110
|
+
```bash
|
|
111
|
+
pi install npm:@aiwayds/pi-think-panel
|
|
112
|
+
```
|
|
173
113
|
|
|
174
|
-
|
|
114
|
+
Or copy the single file (same loader, no build step / 单文件直载,无构建):
|
|
175
115
|
|
|
176
116
|
```bash
|
|
177
117
|
cp extensions/pi-think-panel.ts ~/.pi/agent/extensions/pi-think-panel.ts
|
|
178
118
|
```
|
|
179
119
|
|
|
180
|
-
Then `/reload` in pi (or restart). Remove by deleting the file and
|
|
120
|
+
Then `/reload` in pi (or restart). Remove by deleting the entry/file and
|
|
121
|
+
reloading.
|
|
181
122
|
|
|
182
|
-
>
|
|
183
|
-
> reload 即可卸载。
|
|
123
|
+
> 在 pi 里 `/reload`(或重启)生效;删除对应包/文件并 reload 即可卸载。
|
|
184
124
|
|
|
185
125
|
## Development / 开发
|
|
186
126
|
|
|
187
127
|
```bash
|
|
188
|
-
|
|
128
|
+
npm run typecheck # type-check against installed pi types
|
|
129
|
+
npm run smoke # jiti-loads the entry, verifies the factory shape
|
|
189
130
|
```
|
|
190
131
|
|
|
191
132
|
## Known tradeoffs / 已知取舍
|
|
192
133
|
|
|
193
|
-
-
|
|
194
|
-
|
|
195
|
-
-
|
|
134
|
+
- Think content from replayed/loaded sessions is not captured (no events fire
|
|
135
|
+
on replay).
|
|
136
|
+
- Hiding the native thinking block in chat is pi's own `hideThinkingBlock`
|
|
137
|
+
setting (recommended companion). This extension never writes user settings.
|
|
138
|
+
- The widget only surfaces thinking. Native tool rendering is intentionally
|
|
139
|
+
untouched — pi has no official API to suppress it.
|
|
196
140
|
|
|
197
|
-
> - ctrl+o 被本扩展接管(原本是 app.tools.expand)。
|
|
198
|
-
> - esc / x 完全不消费(正常打字、流式中断不受影响);关闭全文覆盖层只需再按一次 ctrl+o。
|
|
199
141
|
> - 回放/加载的旧会话不产生事件,无法捕获其中的思考内容。
|
|
142
|
+
> - 隐藏聊天区原生思考块请配合 pi 自带的 `hideThinkingBlock` 设置(推荐同开);
|
|
143
|
+
> 本扩展绝不改写用户设置。
|
|
144
|
+
> - widget 只承载思考内容。原生工具渲染刻意不动——pi 没有官方 API 可以关掉它。
|
|
@@ -1,29 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-think-panel —
|
|
2
|
+
* pi-think-panel — a thinking viewport widget above the editor.
|
|
3
3
|
*
|
|
4
|
-
* Captures the model's thinking from message_update events and renders
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* toggled with ctrl+o to read the latest chunk of accumulated thinking,
|
|
9
|
-
* auto-following new content as it streams in (a live "details" view);
|
|
10
|
-
* anchored left so it stays clear of a right-side terminal sidebar.
|
|
11
|
-
* Both overlays are nonCapturing so keyboard focus stays in the editor.
|
|
12
|
-
* Panel background color is configurable via PI_THINK_PANEL_BG (one of pi's
|
|
13
|
-
* theme bg keys, default toolSuccessBg).
|
|
4
|
+
* Captures the model's thinking from message_update events and renders it as
|
|
5
|
+
* a todo-style widget in the extension widget slot (aboveEditor): a scrolling
|
|
6
|
+
* viewport that always shows the LATEST lines. When the wrapped text exceeds
|
|
7
|
+
* the configured height it scrolls up — the newest content stays visible.
|
|
14
8
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
9
|
+
* Rows:
|
|
10
|
+
* row 0 "🧠Thinking: " prefix + the first line of the visible window
|
|
11
|
+
* (tail-truncated to terminal width)
|
|
12
|
+
* rows 1+ continuation lines (N-1 rows for an N-line config; N=1 keeps
|
|
13
|
+
* prefix and text on the single row, exactly "🧠Thinking: xxx")
|
|
14
|
+
*
|
|
15
|
+
* Lines are VISUAL lines after wrapping to the current terminal width
|
|
16
|
+
* (ANSI-aware via pi-tui's wrapTextWithAnsi), so the view reflows on resize.
|
|
17
|
+
*
|
|
18
|
+
* Configuration: /think-panel [1|3|5|7|off] — persisted to
|
|
19
|
+
* ~/.pi/agent/think-panel.json as {"lines": N} (0 = off). With `off` (or no
|
|
20
|
+
* active thinking) the widget renders zero rows and takes no space.
|
|
21
|
+
*
|
|
22
|
+
* The widget is text-only (no overlay, no key grabbing): ctrl+o/ctrl+h stay
|
|
23
|
+
* with pi. Hiding the native thinking block in chat is pi's own
|
|
24
|
+
* `hideThinkingBlock` setting — this extension never writes user settings.
|
|
27
25
|
*/
|
|
28
26
|
|
|
29
27
|
import type {
|
|
@@ -31,157 +29,71 @@ import type {
|
|
|
31
29
|
ExtensionContext,
|
|
32
30
|
Theme,
|
|
33
31
|
} from "@earendil-works/pi-coding-agent";
|
|
34
|
-
import type {
|
|
35
|
-
import {
|
|
32
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
33
|
+
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
36
34
|
import * as fs from "fs";
|
|
37
35
|
import * as os from "os";
|
|
38
36
|
import * as path from "path";
|
|
39
37
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
const WIDGET_KEY = "think-panel:viewport";
|
|
39
|
+
const PREFIX = "🧠Thinking: ";
|
|
40
|
+
/** Cap the retained raw text so per-delta re-wrap stays O(1). 4KB is far
|
|
41
|
+
* more than the largest possible window (7 lines × ~500 cols). */
|
|
42
|
+
const MAX_RETAINED_CHARS = 4000;
|
|
43
|
+
const VALID_LINES = [1, 3, 5, 7] as const;
|
|
44
|
+
type LinesConfig = (typeof VALID_LINES)[number] | 0; // 0 = off
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
const raw = process.env.PI_THINK_PANEL_MAX_LINES;
|
|
50
|
-
if (raw === undefined) return 5;
|
|
51
|
-
const n = Number(raw);
|
|
52
|
-
return Number.isInteger(n) && n > 0 ? n : 5;
|
|
53
|
-
}
|
|
54
|
-
// Min terminal rows for overlay A. When the terminal is resized shorter
|
|
55
|
-
// than this (window dragged down / font zoomed in), the TUI's visible()
|
|
56
|
-
// callback hides the small panel; it reappears automatically once the
|
|
57
|
-
// terminal is tall enough again. Defaults to panel height + margin
|
|
58
|
-
// (MAX_LINES + 6); override via PI_THINK_PANEL_MIN_TERM_ROWS.
|
|
59
|
-
const MIN_TERM_ROWS = readMinTermRows();
|
|
46
|
+
// ── Config (~/.pi/agent/think-panel.json) ─────────────────────────────────
|
|
60
47
|
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
if (raw === undefined) return MAX_LINES + 6;
|
|
64
|
-
const n = Number(raw);
|
|
65
|
-
return Number.isInteger(n) && n > 0 ? n : MAX_LINES + 6;
|
|
48
|
+
function agentDir(): string {
|
|
49
|
+
return process.env.PI_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
|
|
66
50
|
}
|
|
67
|
-
// Auto-hide delay (ms) after thinking ends / the turn settles. Configurable
|
|
68
|
-
// via PI_THINK_PANEL_CLOSE_DELAY_MS (milliseconds, default 1000). Invalid
|
|
69
|
-
// values fall back to the default. Read at module load — /reload to apply.
|
|
70
|
-
const CLOSE_DELAY_MS = readCloseDelayMs();
|
|
71
51
|
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
if (raw === undefined) return 1000;
|
|
75
|
-
const ms = Number(raw);
|
|
76
|
-
return Number.isFinite(ms) && ms >= 0 ? Math.floor(ms) : 1000;
|
|
52
|
+
function configPath(): string {
|
|
53
|
+
return path.join(agentDir(), "think-panel.json");
|
|
77
54
|
}
|
|
78
55
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
"userMessageBg",
|
|
89
|
-
"customMessageBg",
|
|
90
|
-
"toolPendingBg",
|
|
91
|
-
"toolSuccessBg",
|
|
92
|
-
"toolErrorBg",
|
|
93
|
-
] as const;
|
|
94
|
-
type PanelBg = (typeof THEME_BG_COLORS)[number];
|
|
95
|
-
|
|
96
|
-
function readPanelBg(): PanelBg {
|
|
97
|
-
const raw = process.env.PI_THINK_PANEL_BG;
|
|
98
|
-
if (
|
|
99
|
-
raw !== undefined &&
|
|
100
|
-
(THEME_BG_COLORS as readonly string[]).includes(raw)
|
|
101
|
-
)
|
|
102
|
-
return raw as PanelBg;
|
|
103
|
-
return "toolSuccessBg";
|
|
56
|
+
function loadLines(): LinesConfig {
|
|
57
|
+
try {
|
|
58
|
+
const raw = JSON.parse(fs.readFileSync(configPath(), "utf8")) as {
|
|
59
|
+
lines?: unknown;
|
|
60
|
+
};
|
|
61
|
+
return normalizeLines(raw.lines);
|
|
62
|
+
} catch {
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
104
65
|
}
|
|
105
66
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// Layout published by pi-sidebar-panel via globalThis (same process, jiti's
|
|
112
|
-
// shared global realm — no file/module dependency between the two). When the
|
|
113
|
-
// sidebar would be drawn (enabled && terminal wide enough), overlays A/B
|
|
114
|
-
// shrink out of its column band instead of being covered by it. Absent key ⇒
|
|
115
|
-
// sidebar not installed ⇒ no adjustment.
|
|
116
|
-
const SIDEBAR_LAYOUT_KEY = "__piSidebarLayout";
|
|
117
|
-
|
|
118
|
-
interface SidebarLayout {
|
|
119
|
-
enabled: boolean;
|
|
120
|
-
width: number; // rendered sidebar width in cols
|
|
121
|
-
minWidth: number; // terminal cols below which the sidebar is not drawn
|
|
67
|
+
function normalizeLines(value: unknown): LinesConfig {
|
|
68
|
+
if (typeof value !== "number") return 1;
|
|
69
|
+
if ((VALID_LINES as readonly number[]).includes(value)) return value as LinesConfig;
|
|
70
|
+
return 1;
|
|
122
71
|
}
|
|
123
72
|
|
|
124
|
-
|
|
125
|
-
let thinkingEnabled = false;
|
|
126
|
-
let hideThinkingBlock = false;
|
|
127
|
-
let thinkText = ""; // current thinking block (complete text of the active message)
|
|
128
|
-
let thinkEnded = false; // current block has finished → render a trailing ------ divider
|
|
129
|
-
let history: string[] = []; // completed thinking blocks from earlier messages
|
|
130
|
-
let manuallyOpened = false; // opened via ctrl+o → auto-hide stands down
|
|
131
|
-
let manuallyHidden = false; // ctrl+h pressed → A manually hidden; ctrl+h again or a new block re-shows it
|
|
132
|
-
let fullOverlayOpen = false; // overlay B (full-text) visible
|
|
133
|
-
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
134
|
-
let tui: TUI | undefined;
|
|
135
|
-
let overlayA: OverlayHandle | undefined;
|
|
136
|
-
let overlayB: OverlayHandle | undefined;
|
|
137
|
-
let inputUnsub: (() => void) | null = null;
|
|
138
|
-
// Width currently mounted for overlays A/B (sidebar-aware); changed ⇒ remount.
|
|
139
|
-
type OverlayWidth = number | `${number}%`;
|
|
140
|
-
let mountedAWidth: OverlayWidth | undefined;
|
|
141
|
-
let mountedBWidth: OverlayWidth | undefined;
|
|
142
|
-
|
|
143
|
-
/** Layout registry read — never throws; absent ⇒ sidebar not installed. */
|
|
144
|
-
function sidebarLayout(): SidebarLayout {
|
|
73
|
+
function saveLines(lines: LinesConfig): void {
|
|
145
74
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
enabled: s.enabled,
|
|
152
|
-
width: s.width,
|
|
153
|
-
minWidth: typeof s.minWidth === "number" ? s.minWidth : 0,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
}
|
|
75
|
+
fs.writeFileSync(
|
|
76
|
+
configPath(),
|
|
77
|
+
JSON.stringify({ lines }, null, 2) + "\n",
|
|
78
|
+
"utf8",
|
|
79
|
+
);
|
|
157
80
|
} catch {
|
|
158
|
-
/*
|
|
81
|
+
/* persistence is best-effort; the in-session value still applies */
|
|
159
82
|
}
|
|
160
|
-
return { enabled: false, width: 0, minWidth: 0 };
|
|
161
83
|
}
|
|
162
84
|
|
|
163
|
-
|
|
164
|
-
function overlayWidthA(): OverlayWidth {
|
|
165
|
-
const s = sidebarLayout();
|
|
166
|
-
const cols = tui?.terminal.columns ?? 0;
|
|
167
|
-
if (s.enabled && s.minWidth > 0 && cols >= s.minWidth)
|
|
168
|
-
return Math.max(20, cols - s.width - 3); // offsetX 1 + 2-col breathing room
|
|
169
|
-
return PANEL_WIDTH_PCT;
|
|
170
|
-
}
|
|
85
|
+
// ── Module state (survives in-process session switches via jiti cache) ────
|
|
171
86
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return Math.max(20, cols - s.width - 4); // offsetX 2 + 2-col breathing room
|
|
178
|
-
return "80%";
|
|
179
|
-
}
|
|
87
|
+
let lines: LinesConfig = loadLines();
|
|
88
|
+
let tui: TUI | undefined;
|
|
89
|
+
let blocks: string[] = []; // completed thinking blocks of the current agent run
|
|
90
|
+
let blockText = ""; // the streaming block
|
|
91
|
+
let thinkActive = false; // true from first thinking_start until agent_settled
|
|
180
92
|
|
|
181
93
|
/** Extract the complete accumulated thinking text from an assistant message. */
|
|
182
94
|
function extractThinking(message: unknown): string {
|
|
183
|
-
// AgentMessage
|
|
184
|
-
//
|
|
95
|
+
// AgentMessage is a union whose custom members have no `content` array —
|
|
96
|
+
// read defensively.
|
|
185
97
|
const content = (message as { content?: unknown } | null)?.content;
|
|
186
98
|
if (!Array.isArray(content)) return "";
|
|
187
99
|
const parts = content as Array<{ type?: string; thinking?: string }>;
|
|
@@ -191,460 +103,153 @@ function extractThinking(message: unknown): string {
|
|
|
191
103
|
.join("\n");
|
|
192
104
|
}
|
|
193
105
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
process.env.PI_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
|
|
199
|
-
const raw = JSON.parse(
|
|
200
|
-
fs.readFileSync(path.join(agentDir, "settings.json"), "utf8"),
|
|
201
|
-
) as {
|
|
202
|
-
hideThinkingBlock?: boolean;
|
|
203
|
-
};
|
|
204
|
-
return raw.hideThinkingBlock ?? false;
|
|
205
|
-
} catch {
|
|
206
|
-
return false;
|
|
207
|
-
}
|
|
106
|
+
function resetViewport(): void {
|
|
107
|
+
blocks = [];
|
|
108
|
+
blockText = "";
|
|
109
|
+
thinkActive = false;
|
|
208
110
|
}
|
|
209
111
|
|
|
210
|
-
/**
|
|
211
|
-
function
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const current = thinkEnded && thinkText ? thinkText + "\n------" : thinkText;
|
|
216
|
-
if (!completed) return current;
|
|
217
|
-
return current ? `${completed}\n\n${current}` : completed;
|
|
112
|
+
/** All thinking text of the current run: blocks joined by a blank line. */
|
|
113
|
+
function viewportText(): string {
|
|
114
|
+
const completed = blocks.join("\n\n");
|
|
115
|
+
if (!completed) return blockText;
|
|
116
|
+
return blockText ? `${completed}\n\n${blockText}` : completed;
|
|
218
117
|
}
|
|
219
118
|
|
|
220
|
-
/**
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
* isKeyRelease/isKeyRepeat are not exported from the package barrel, so check
|
|
226
|
-
* inline. Bracketed paste can contain these patterns (e.g. MAC addresses), so
|
|
227
|
-
* paste is treated as one event — the same guard pi-tui itself uses.
|
|
228
|
-
*/
|
|
229
|
-
function isKeyReleaseOrRepeat(data: string): boolean {
|
|
230
|
-
if (data.includes("\x1b[200~")) return false;
|
|
231
|
-
return /:(?:2|3)[u~ABCDHF]/.test(data);
|
|
232
|
-
}
|
|
119
|
+
/** Render the widget rows: latest N wrapped lines, prefix on row 0. */
|
|
120
|
+
function renderViewport(theme: Theme, width: number): string[] {
|
|
121
|
+
if (lines === 0 || !thinkActive) return [];
|
|
122
|
+
const text = viewportText();
|
|
123
|
+
if (!text.trim()) return [];
|
|
233
124
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (hideThinkingBlock) return title;
|
|
238
|
-
return (
|
|
239
|
-
title + theme.italic(theme.fg("dim", " chat shows think · Ctrl+T hides"))
|
|
240
|
-
);
|
|
241
|
-
}
|
|
125
|
+
// Keep only the tail so wrapping stays cheap on long thinking streams.
|
|
126
|
+
const tail =
|
|
127
|
+
text.length > MAX_RETAINED_CHARS ? text.slice(-MAX_RETAINED_CHARS) : text;
|
|
242
128
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const bg = (s: string) => theme.bg(PANEL_BG, s);
|
|
247
|
-
const border = (s: string) => theme.fg("border", s);
|
|
248
|
-
const pad = (s: string) => truncateToWidth(s, innerW, "...", true);
|
|
249
|
-
// Rows are exactly `width` visible chars (pad=true + 2 border cols), so bg
|
|
250
|
-
// covers the full panel area including padding and borders.
|
|
251
|
-
const row = (s: string) => bg(border("│") + pad(s) + border("│"));
|
|
252
|
-
// Think lines use the chat code-block color (mdCodeBlock) + 2-space indent,
|
|
253
|
-
// matching pi's markdown code-block idiom so think text reads as code.
|
|
254
|
-
const code = (s: string) => theme.fg("mdCodeBlock", s);
|
|
255
|
-
const rows: string[] = [];
|
|
256
|
-
rows.push(bg(border("┌" + "─".repeat(innerW) + "┐")));
|
|
257
|
-
rows.push(row(titleLine(theme, " ⌃O 展开 · ⌃H 隐藏")));
|
|
258
|
-
const text = fullThinkText();
|
|
259
|
-
const lines = text ? text.split(/\r?\n/).slice(-MAX_LINES) : [];
|
|
260
|
-
if (lines.length === 0) {
|
|
261
|
-
rows.push(row(theme.fg("dim", " (no thinking yet)")));
|
|
262
|
-
} else {
|
|
263
|
-
for (const l of lines)
|
|
264
|
-
rows.push(
|
|
265
|
-
row(l === "------" ? theme.fg("dim", " ------") : code(" " + l)),
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
rows.push(bg(border("└" + "─".repeat(innerW) + "┘")));
|
|
269
|
-
return rows;
|
|
270
|
-
}
|
|
129
|
+
const wrapped = wrapTextWithAnsi(tail, Math.max(1, width));
|
|
130
|
+
const window = wrapped.slice(-lines);
|
|
131
|
+
if (window.length === 0) return [];
|
|
271
132
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const innerW = Math.max(1, width - 2);
|
|
275
|
-
const bg = (s: string) => theme.bg(PANEL_BG, s);
|
|
276
|
-
const border = (s: string) => theme.fg("border", s);
|
|
277
|
-
const pad = (s: string) => truncateToWidth(s, innerW, "...", true);
|
|
278
|
-
// Rows are exactly `width` visible chars (pad=true + 2 border cols), so bg
|
|
279
|
-
// covers the full panel area including padding and borders.
|
|
280
|
-
const row = (s: string) => bg(border("│") + pad(s) + border("│"));
|
|
281
|
-
// Think lines use the chat code-block color (mdCodeBlock) + 2-space indent,
|
|
282
|
-
// matching pi's markdown code-block idiom so think text reads as code.
|
|
283
|
-
const code = (s: string) => theme.fg("mdCodeBlock", s);
|
|
133
|
+
// Row 0: prefix + the window's first line (prefix overlaps the head of the
|
|
134
|
+
// visible text — the tail below is pure). Tail-truncate every row.
|
|
284
135
|
const rows: string[] = [];
|
|
285
|
-
rows.push(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
// bottom border) are not hard-truncated by the TUI. Show the TAIL so the
|
|
294
|
-
// view follows the newest content (a live "details" view — auto-scrolls
|
|
295
|
-
// down as new think lines stream in, same behavior as overlay A).
|
|
296
|
-
const termRows = tui?.terminal.rows ?? 40;
|
|
297
|
-
const maxBody = Math.max(2, Math.floor(termRows * 0.9) - 4);
|
|
298
|
-
const shown = lines.length > maxBody ? lines.slice(-maxBody) : lines;
|
|
299
|
-
if (lines.length > maxBody) {
|
|
300
|
-
rows.push(row(theme.fg("dim", " …(更早内容已省略)")));
|
|
301
|
-
}
|
|
302
|
-
for (const l of shown)
|
|
303
|
-
rows.push(
|
|
304
|
-
row(l === "------" ? theme.fg("dim", " ------") : code(" " + l)),
|
|
305
|
-
);
|
|
136
|
+
rows.push(
|
|
137
|
+
truncateToWidth(
|
|
138
|
+
theme.fg("accent", PREFIX) + theme.fg("mdCodeBlock", window[0]),
|
|
139
|
+
width,
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
for (let i = 1; i < window.length; i++) {
|
|
143
|
+
rows.push(truncateToWidth(theme.fg("mdCodeBlock", window[i]), width));
|
|
306
144
|
}
|
|
307
|
-
rows.push(bg(border("└" + "─".repeat(innerW) + "┘")));
|
|
308
145
|
return rows;
|
|
309
146
|
}
|
|
310
147
|
|
|
311
|
-
|
|
312
|
-
// Streaming flag — guards escape consumption (never block stream-abort).
|
|
313
|
-
pi.on("agent_start", (_event, ctx) => {
|
|
314
|
-
if (ctx?.mode !== "tui") return;
|
|
315
|
-
});
|
|
316
|
-
// Set (or reset) the auto-hide timer; new think activity cancels it.
|
|
317
|
-
function armCloseTimer(): void {
|
|
318
|
-
if (closeTimer !== undefined) clearTimeout(closeTimer);
|
|
319
|
-
closeTimer = setTimeout(() => {
|
|
320
|
-
closeTimer = undefined;
|
|
321
|
-
if (EMPTY_THINK_MODE === "hide" && !manuallyOpened) {
|
|
322
|
-
overlayA?.setHidden(true);
|
|
323
|
-
}
|
|
324
|
-
}, CLOSE_DELAY_MS);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
pi.on("agent_settled", (_event, ctx) => {
|
|
328
|
-
if (ctx?.mode !== "tui") return;
|
|
329
|
-
// Hide once the turn has been idle for CLOSE_DELAY_MS; any new think
|
|
330
|
-
// activity cancels the timer and re-shows the panel.
|
|
331
|
-
armCloseTimer();
|
|
332
|
-
});
|
|
148
|
+
// ── Extension entry ───────────────────────────────────────────────────────
|
|
333
149
|
|
|
334
|
-
|
|
335
|
-
pi.on("
|
|
150
|
+
export default function (pi: ExtensionAPI): void {
|
|
151
|
+
pi.on("session_start", (_event, ctx) => {
|
|
336
152
|
if (ctx?.mode !== "tui") return;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
153
|
+
// Fresh session → fresh viewport.
|
|
154
|
+
resetViewport();
|
|
155
|
+
|
|
156
|
+
// Register-once per session; render() reads live module state, content
|
|
157
|
+
// updates just need requestRender().
|
|
158
|
+
ctx.ui.setWidget(WIDGET_KEY, (t, theme) => {
|
|
159
|
+
tui = t;
|
|
160
|
+
return {
|
|
161
|
+
dispose() {
|
|
162
|
+
if (tui === t) tui = undefined;
|
|
163
|
+
},
|
|
164
|
+
invalidate() {
|
|
165
|
+
t.requestRender();
|
|
166
|
+
},
|
|
167
|
+
render(width: number): string[] {
|
|
168
|
+
return renderViewport(theme, width);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
});
|
|
356
172
|
});
|
|
357
173
|
|
|
358
174
|
// Capture: message_update always carries the COMPLETE thinking text.
|
|
359
175
|
pi.on("message_update", (event, ctx) => {
|
|
360
176
|
if (ctx?.mode !== "tui") return;
|
|
361
|
-
if (!thinkingEnabled) return;
|
|
362
177
|
const t = event.assistantMessageEvent.type;
|
|
363
178
|
if (!t.startsWith("thinking_")) return;
|
|
364
179
|
if (t === "thinking_start") {
|
|
365
|
-
// A new block is starting — rotate the finished one into
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
|
|
180
|
+
// A new block is starting — rotate the finished one into blocks.
|
|
181
|
+
if (blockText) {
|
|
182
|
+
blocks.push(blockText);
|
|
183
|
+
blockText = "";
|
|
369
184
|
}
|
|
370
|
-
thinkEnded = false;
|
|
371
|
-
manuallyHidden = false; // fresh block → auto-show resumes
|
|
372
185
|
} else {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (t === "thinking_end") {
|
|
377
|
-
// Thinking finished — arm the auto-hide timer (measured from the actual
|
|
378
|
-
// end, robust to event order around agent_settled).
|
|
379
|
-
if (closeTimer === undefined) armCloseTimer();
|
|
380
|
-
} else {
|
|
381
|
-
// thinking_start / thinking_delta — active thinking cancels a pending
|
|
382
|
-
// auto-hide.
|
|
383
|
-
if (closeTimer !== undefined) {
|
|
384
|
-
clearTimeout(closeTimer);
|
|
385
|
-
closeTimer = undefined;
|
|
186
|
+
blockText = extractThinking(event.message);
|
|
187
|
+
if (blockText.length > MAX_RETAINED_CHARS) {
|
|
188
|
+
blockText = blockText.slice(-MAX_RETAINED_CHARS);
|
|
386
189
|
}
|
|
387
190
|
}
|
|
388
|
-
|
|
389
|
-
// user-requested hide (ctrl+h).
|
|
390
|
-
if (!fullOverlayOpen && !manuallyHidden) overlayA?.setHidden(false);
|
|
391
|
-
// Sidebar toggled or terminal resized? Re-mount with the corrected
|
|
392
|
-
// width (cheap no-op unless the width actually changed).
|
|
393
|
-
syncOverlayWidths(ctx);
|
|
191
|
+
thinkActive = true;
|
|
394
192
|
tui?.requestRender();
|
|
395
193
|
});
|
|
396
194
|
|
|
397
|
-
//
|
|
398
|
-
pi.on("
|
|
195
|
+
// Turn fully settled (stream done / aborted) → viewport goes away.
|
|
196
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
399
197
|
if (ctx?.mode !== "tui") return;
|
|
198
|
+
resetViewport();
|
|
199
|
+
tui?.requestRender();
|
|
200
|
+
});
|
|
400
201
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
overlayA = undefined;
|
|
408
|
-
overlayB = undefined;
|
|
409
|
-
mountedAWidth = undefined;
|
|
410
|
-
mountedBWidth = undefined;
|
|
411
|
-
fullOverlayOpen = false;
|
|
412
|
-
manuallyOpened = false;
|
|
413
|
-
manuallyHidden = false;
|
|
414
|
-
thinkText = "";
|
|
415
|
-
thinkEnded = false;
|
|
416
|
-
history = [];
|
|
417
|
-
hideThinkingBlock = readHideThinkingBlock();
|
|
418
|
-
thinkingEnabled = (ctx.thinkingLevel ?? "off") !== "off";
|
|
419
|
-
|
|
420
|
-
// (Re)register the key listener; tear down the previous session's one.
|
|
421
|
-
if (inputUnsub) {
|
|
422
|
-
inputUnsub();
|
|
423
|
-
inputUnsub = null;
|
|
202
|
+
// Thinking switched off → nothing to show.
|
|
203
|
+
pi.on("thinking_level_select", (event, ctx) => {
|
|
204
|
+
if (ctx?.mode !== "tui") return;
|
|
205
|
+
if (event.level === "off") {
|
|
206
|
+
resetViewport();
|
|
207
|
+
tui?.requestRender();
|
|
424
208
|
}
|
|
425
|
-
inputUnsub = ctx.ui.onTerminalInput((data) => {
|
|
426
|
-
// Kitty-protocol release/repeat events match the same keys — ignore them
|
|
427
|
-
// so each keypress fires exactly once (fixes the ctrl+o double-toggle).
|
|
428
|
-
if (isKeyReleaseOrRepeat(data)) return undefined;
|
|
429
|
-
if (matchesKey(data, "ctrl+o")) {
|
|
430
|
-
syncOverlayWidths(ctx); // sidebar may have toggled while idle
|
|
431
|
-
if (!thinkingEnabled) {
|
|
432
|
-
ctx.ui.notify("Think panel: thinking is off", "info");
|
|
433
|
-
return { consume: true };
|
|
434
|
-
}
|
|
435
|
-
if (fullOverlayOpen) {
|
|
436
|
-
overlayB?.setHidden(true);
|
|
437
|
-
fullOverlayOpen = false;
|
|
438
|
-
// Collapse back onto the small panel (unless the user hid A).
|
|
439
|
-
if (!manuallyHidden) overlayA?.setHidden(false);
|
|
440
|
-
} else {
|
|
441
|
-
overlayB?.setHidden(false);
|
|
442
|
-
fullOverlayOpen = true;
|
|
443
|
-
manuallyOpened = true; // opening counts as manual — auto-hide stands down
|
|
444
|
-
overlayA?.setHidden(true); // hide A so it doesn't show through behind B
|
|
445
|
-
}
|
|
446
|
-
return { consume: true };
|
|
447
|
-
}
|
|
448
|
-
if (
|
|
449
|
-
matchesKey(data, "ctrl+h") &&
|
|
450
|
-
!fullOverlayOpen &&
|
|
451
|
-
overlayA !== undefined
|
|
452
|
-
) {
|
|
453
|
-
// Toggle the small panel: visible → hide it, hidden → bring it back.
|
|
454
|
-
if (overlayA.isHidden()) {
|
|
455
|
-
overlayA.setHidden(false);
|
|
456
|
-
manuallyHidden = false;
|
|
457
|
-
} else {
|
|
458
|
-
overlayA.setHidden(true);
|
|
459
|
-
manuallyHidden = true;
|
|
460
|
-
}
|
|
461
|
-
return { consume: true };
|
|
462
|
-
}
|
|
463
|
-
return undefined;
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
// Mount the overlays once per session (visibility toggled afterwards).
|
|
467
|
-
if (thinkingEnabled) mountOverlays(ctx);
|
|
468
209
|
});
|
|
469
210
|
|
|
470
211
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
471
212
|
if (ctx?.mode !== "tui") return;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
inputUnsub = null;
|
|
475
|
-
}
|
|
476
|
-
if (closeTimer !== undefined) {
|
|
477
|
-
clearTimeout(closeTimer);
|
|
478
|
-
closeTimer = undefined;
|
|
479
|
-
}
|
|
480
|
-
overlayA?.setHidden(true);
|
|
481
|
-
overlayB?.setHidden(true);
|
|
482
|
-
overlayA = undefined;
|
|
483
|
-
overlayB = undefined;
|
|
484
|
-
fullOverlayOpen = false;
|
|
213
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
214
|
+
resetViewport();
|
|
485
215
|
tui = undefined;
|
|
486
216
|
});
|
|
487
217
|
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
overlayA = h;
|
|
526
|
-
if (EMPTY_THINK_MODE === "hide") h.setHidden(true);
|
|
527
|
-
},
|
|
528
|
-
},
|
|
529
|
-
);
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
function mountOverlayB(ctx: ExtensionContext): void {
|
|
533
|
-
mountedBWidth = overlayWidthB();
|
|
534
|
-
void ctx.ui.custom(
|
|
535
|
-
(t, theme) => {
|
|
536
|
-
tui = t;
|
|
537
|
-
queueMicrotask(() => syncOverlayWidths(ctx));
|
|
538
|
-
return {
|
|
539
|
-
dispose() {},
|
|
540
|
-
invalidate() {
|
|
541
|
-
t.requestRender();
|
|
542
|
-
},
|
|
543
|
-
render(width: number): string[] {
|
|
544
|
-
return renderFullPanel(theme, width);
|
|
545
|
-
},
|
|
546
|
-
};
|
|
547
|
-
},
|
|
548
|
-
{
|
|
549
|
-
overlay: true,
|
|
550
|
-
overlayOptions: {
|
|
551
|
-
anchor: "left-center",
|
|
552
|
-
offsetX: 2,
|
|
553
|
-
width: mountedBWidth,
|
|
554
|
-
maxHeight: "90%",
|
|
555
|
-
margin: { top: 1 },
|
|
556
|
-
nonCapturing: true,
|
|
557
|
-
},
|
|
558
|
-
onHandle: (h) => {
|
|
559
|
-
overlayB = h;
|
|
560
|
-
h.setHidden(true);
|
|
561
|
-
},
|
|
562
|
-
},
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/** Re-mount overlays A/B when the sidebar-aware width changed. */
|
|
567
|
-
function syncOverlayWidths(ctx: ExtensionContext): void {
|
|
568
|
-
const wa = overlayWidthA();
|
|
569
|
-
if (wa !== mountedAWidth) remountOverlayA(ctx, wa);
|
|
570
|
-
const wb = overlayWidthB();
|
|
571
|
-
if (wb !== mountedBWidth) remountOverlayB(ctx, wb);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
function remountOverlayA(ctx: ExtensionContext, width: OverlayWidth): void {
|
|
575
|
-
const old = overlayA;
|
|
576
|
-
const wasVisible = old?.isHidden() === false;
|
|
577
|
-
old?.hide();
|
|
578
|
-
overlayA = undefined;
|
|
579
|
-
mountedAWidth = width;
|
|
580
|
-
void ctx.ui.custom(
|
|
581
|
-
(t, theme) => {
|
|
582
|
-
tui = t;
|
|
583
|
-
queueMicrotask(() => syncOverlayWidths(ctx));
|
|
584
|
-
return {
|
|
585
|
-
dispose() {},
|
|
586
|
-
invalidate() {
|
|
587
|
-
t.requestRender();
|
|
588
|
-
},
|
|
589
|
-
render(width: number): string[] {
|
|
590
|
-
return renderTopPanel(theme, width);
|
|
591
|
-
},
|
|
592
|
-
};
|
|
593
|
-
},
|
|
594
|
-
{
|
|
595
|
-
overlay: true,
|
|
596
|
-
overlayOptions: {
|
|
597
|
-
anchor: "top-left",
|
|
598
|
-
offsetX: 1,
|
|
599
|
-
offsetY: 1,
|
|
600
|
-
visible: (_cols, rows) => rows >= MIN_TERM_ROWS,
|
|
601
|
-
width,
|
|
602
|
-
nonCapturing: true,
|
|
603
|
-
},
|
|
604
|
-
onHandle: (h) => {
|
|
605
|
-
overlayA = h;
|
|
606
|
-
// Preserve visibility across the remount: mid-thinking stays
|
|
607
|
-
// visible; idle ("hide" mode) and open-B states stay hidden.
|
|
608
|
-
h.setHidden(fullOverlayOpen || !(wasVisible && thinkingEnabled));
|
|
609
|
-
},
|
|
610
|
-
},
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function remountOverlayB(ctx: ExtensionContext, width: OverlayWidth): void {
|
|
615
|
-
const old = overlayB;
|
|
616
|
-
old?.hide();
|
|
617
|
-
overlayB = undefined;
|
|
618
|
-
mountedBWidth = width;
|
|
619
|
-
void ctx.ui.custom(
|
|
620
|
-
(t, theme) => {
|
|
621
|
-
tui = t;
|
|
622
|
-
queueMicrotask(() => syncOverlayWidths(ctx));
|
|
623
|
-
return {
|
|
624
|
-
dispose() {},
|
|
625
|
-
invalidate() {
|
|
626
|
-
t.requestRender();
|
|
627
|
-
},
|
|
628
|
-
render(width: number): string[] {
|
|
629
|
-
return renderFullPanel(theme, width);
|
|
630
|
-
},
|
|
631
|
-
};
|
|
632
|
-
},
|
|
633
|
-
{
|
|
634
|
-
overlay: true,
|
|
635
|
-
overlayOptions: {
|
|
636
|
-
anchor: "left-center",
|
|
637
|
-
offsetX: 2,
|
|
638
|
-
width,
|
|
639
|
-
maxHeight: "90%",
|
|
640
|
-
margin: { top: 1 },
|
|
641
|
-
nonCapturing: true,
|
|
642
|
-
},
|
|
643
|
-
onHandle: (h) => {
|
|
644
|
-
overlayB = h;
|
|
645
|
-
h.setHidden(!fullOverlayOpen); // preserve open state
|
|
646
|
-
},
|
|
647
|
-
},
|
|
648
|
-
);
|
|
649
|
-
}
|
|
218
|
+
// /think-panel [1|3|5|7|off] — switch height, persisted.
|
|
219
|
+
pi.registerCommand("think-panel", {
|
|
220
|
+
description:
|
|
221
|
+
"Thinking viewport above the editor: /think-panel [1|3|5|7|off] (persisted)",
|
|
222
|
+
handler: async (args, ctx) => {
|
|
223
|
+
const raw = (args ?? "").trim().toLowerCase();
|
|
224
|
+
if (raw === "" || raw === "status") {
|
|
225
|
+
const state = lines === 0 ? "off" : `${lines} line(s)`;
|
|
226
|
+
ctx.ui.notify(
|
|
227
|
+
`Think viewport: ${state}\nUsage: /think-panel [1|3|5|7|off]`,
|
|
228
|
+
"info",
|
|
229
|
+
);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (raw === "off" || raw === "0") {
|
|
233
|
+
lines = 0;
|
|
234
|
+
} else {
|
|
235
|
+
const n = Number(raw);
|
|
236
|
+
if (!(VALID_LINES as readonly number[]).includes(n)) {
|
|
237
|
+
ctx.ui.notify(
|
|
238
|
+
`Think viewport: unknown arg "${raw}". Usage: /think-panel [1|3|5|7|off]`,
|
|
239
|
+
"warning",
|
|
240
|
+
);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
lines = n as LinesConfig;
|
|
244
|
+
}
|
|
245
|
+
saveLines(lines);
|
|
246
|
+
// No state reset: renderViewport() reads `lines` on every render, so the
|
|
247
|
+
// new height applies on the next paint without a mid-stream blank.
|
|
248
|
+
ctx.ui.notify(
|
|
249
|
+
`Think viewport: ${lines === 0 ? "off" : `${lines} line(s)`} (persisted)`,
|
|
250
|
+
"info",
|
|
251
|
+
);
|
|
252
|
+
tui?.requestRender();
|
|
253
|
+
},
|
|
254
|
+
});
|
|
650
255
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/pi-think-panel",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "pi-coding-agent extension —
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "pi-coding-agent extension — thinking viewport widget above the editor, live-scrolls the model's reasoning (1/3/5/7 lines)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi-extension",
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"author": "fan56",
|
|
15
15
|
"main": "extensions/pi-think-panel.ts",
|
|
16
16
|
"pi": {
|
|
17
|
-
"extensions": [
|
|
17
|
+
"extensions": [
|
|
18
|
+
"extensions/pi-think-panel.ts"
|
|
19
|
+
]
|
|
18
20
|
},
|
|
19
21
|
"files": [
|
|
20
22
|
"extensions",
|
|
@@ -33,12 +35,14 @@
|
|
|
33
35
|
"@earendil-works/pi-tui": "*"
|
|
34
36
|
},
|
|
35
37
|
"devDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
36
39
|
"@earendil-works/pi-tui": "*",
|
|
40
|
+
"@types/node": "^22.0.0",
|
|
37
41
|
"jiti": "^2.4.0",
|
|
38
|
-
"typescript": "^5.6.0"
|
|
39
|
-
"@types/node": "^22.0.0"
|
|
42
|
+
"typescript": "^5.6.0"
|
|
40
43
|
},
|
|
41
44
|
"scripts": {
|
|
45
|
+
"smoke": "node smoke-test.cjs",
|
|
42
46
|
"typecheck": "tsc --noEmit"
|
|
43
47
|
},
|
|
44
48
|
"engines": {
|