@aiwayds/pi-think-panel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/extensions/pi-think-panel.ts +590 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fan56
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# pi-think-panel
|
|
2
|
+
|
|
3
|
+
Toggleable "think" content panel for the pi coding agent TUI.
|
|
4
|
+
|
|
5
|
+
> 可在 pi 编码代理 TUI 中切换显示的"思考内容"面板。
|
|
6
|
+
|
|
7
|
+
## Demo / 演示
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Why / 为什么做这个扩展
|
|
12
|
+
|
|
13
|
+
Reasoning models now spend longer and longer "thinking" before they answer —
|
|
14
|
+
and both ways of surfacing that output are bad:
|
|
15
|
+
|
|
16
|
+
- **Hidden** (`hideThinkingBlock` on, or the block scrolled out of view): the
|
|
17
|
+
TUI looks frozen. You can't tell whether the model is stuck, exploring, or
|
|
18
|
+
about to finish — there is no health monitoring and no progress signal.
|
|
19
|
+
- **Fully shown**: a wall of reasoning text floods the chat pane, pushing the
|
|
20
|
+
real content around and making the conversation hard to read.
|
|
21
|
+
|
|
22
|
+
This extension sits in between. A small floating panel keeps the **last few
|
|
23
|
+
lines** of live reasoning always in view — a heartbeat that shows the model is
|
|
24
|
+
alive and where its head is at — and `ctrl+o` opens a wider full-text view
|
|
25
|
+
when you actually want to read the details. When thinking stops, the panel
|
|
26
|
+
quietly hides itself again.
|
|
27
|
+
|
|
28
|
+
> **中文**:推理模型的思考时间越来越长,而两种展示方式都不理想——
|
|
29
|
+
> 隐藏(`hideThinkingBlock` 开启或滚动出视野)时 TUI 看起来像卡住,无法判断
|
|
30
|
+
> 模型是卡住了、在探索还是快完成,缺乏健康监控和进度信号;全部显示时,
|
|
31
|
+
> 大段推理文本冲击聊天区,挤乱真实内容,对话难以阅读。
|
|
32
|
+
> 本扩展取中间态:小面板常驻显示**最近几行**实时推理("心跳",证明模型活着、
|
|
33
|
+
> 思路在哪),需要时 `ctrl+o` 展开全文详情;思考结束面板自动隐藏。
|
|
34
|
+
|
|
35
|
+
## Layout / 布局
|
|
36
|
+
|
|
37
|
+
Small panel (overlay A) floats top-left while the model is thinking: last 10
|
|
38
|
+
lines, auto-following the newest content, chat stays clean:
|
|
39
|
+
|
|
40
|
+
> 小面板(overlay A):模型思考时浮在左上角,显示最近 10 行,跟随最新内容,
|
|
41
|
+
> 聊天区保持干净。
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
45
|
+
│ ╭─ Thinking… ⌃O 展开 · ⌃H 隐藏 ────────────────────────╮ │
|
|
46
|
+
│ │ reasoning line 1 │ │
|
|
47
|
+
│ │ reasoning line 2 │ │
|
|
48
|
+
│ │ … (last 10 lines, follows newest) │ │
|
|
49
|
+
│ ╰───────────────────────────────────────────────────────╯ │
|
|
50
|
+
│ │
|
|
51
|
+
│ chat history stays clean — think text never floods it │
|
|
52
|
+
│ │
|
|
53
|
+
│ editor │
|
|
54
|
+
└──────────────────────────────────────────────────────────────┘
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`ctrl+o` opens the full-text overlay (overlay B, 80% width, up to 90% height)
|
|
58
|
+
— a live "details" view, always scrolled to the newest content:
|
|
59
|
+
|
|
60
|
+
> `ctrl+o` 打开全文覆盖层(overlay B,80% 宽、最高 90% 高)—— 实时"详情"
|
|
61
|
+
> 视图,始终滚动跟随最新内容。
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
65
|
+
│ │
|
|
66
|
+
│ ╭─ Thinking… ⌃O 收起 ─────────────────────────────────╮ │
|
|
67
|
+
│ │ …(earlier lines omitted) │ │
|
|
68
|
+
│ │ reasoning line │ │
|
|
69
|
+
│ │ reasoning line ← auto-scrolls with new content │ │
|
|
70
|
+
│ │ reasoning line │ │
|
|
71
|
+
│ ╰──────────────────────────────────────────────────────╯ │
|
|
72
|
+
│ │
|
|
73
|
+
│ editor │
|
|
74
|
+
└──────────────────────────────────────────────────────────────┘
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
With `pi-sidebar-panel` installed **and** enabled (terminal ≥ 100 cols), both
|
|
78
|
+
overlays shrink to leave the sidebar's right-hand band free:
|
|
79
|
+
|
|
80
|
+
> 安装了 `pi-sidebar-panel` 且已开启(终端 ≥ 100 列)时,两个覆盖层自动收窄,
|
|
81
|
+
> 让出右侧 sidebar 区域。
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
┌───────────────────────────────────────────────┬────────────┐
|
|
85
|
+
│ ╭─ Thinking… ─────────────╮ │ sidebar │
|
|
86
|
+
│ │ reasoning line … │ │ todos │
|
|
87
|
+
│ ╰─────────────────────────╯ │ agents │
|
|
88
|
+
│ └────────────┘
|
|
89
|
+
│ chat / editor
|
|
90
|
+
└─────────────────────────────────────────────────────────────
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
If the sidebar is absent (no `__piSidebarLayout` on `globalThis`) or the
|
|
94
|
+
terminal is narrow, the panels simply use their full width — standalone, no
|
|
95
|
+
dependency, no error.
|
|
96
|
+
|
|
97
|
+
> 未安装 sidebar(`globalThis` 上没有 `__piSidebarLayout`)或终端过窄时,面板
|
|
98
|
+
> 直接使用全宽 —— 可独立运行,无依赖、不报错。
|
|
99
|
+
|
|
100
|
+
## Keys / 快捷键
|
|
101
|
+
|
|
102
|
+
| Key | Action |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| ctrl+o | Toggle between the small panel and the full-text overlay (thinking off → info notice) |
|
|
105
|
+
| ctrl+h | Toggle the small panel on/off (stays hidden until toggled back or the next thinking block starts) |
|
|
106
|
+
|
|
107
|
+
| 按键 | 行为 |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| ctrl+o | 小面板与大窗口(全文视图)之间切换(思考关闭时 → 提示信息) |
|
|
110
|
+
| ctrl+h | 切换小面板显示/隐藏(保持状态,直到再次切换或下一个思考块开始) |
|
|
111
|
+
|
|
112
|
+
Note: ctrl+o is normally reserved for the tools panel (app.tools.expand); this extension takes it over — rebind it in your keybindings config if you prefer.
|
|
113
|
+
|
|
114
|
+
> 说明:ctrl+o 原本是工具面板(app.tools.expand)的保留键,本扩展接管了它 ——
|
|
115
|
+
> 如需保留可在 keybindings 配置里重新绑定。
|
|
116
|
+
|
|
117
|
+
## What it does / 功能
|
|
118
|
+
|
|
119
|
+
- Captures the model's thinking and shows the last 10 lines in a bordered panel above the input editor; ctrl+o opens a wider full-text view (80% width).
|
|
120
|
+
- Auto-shows while the model is thinking (when thinking is enabled); when no thinking is happening it either hides (default `hide` mode) or keeps showing the last think text (`last` mode).
|
|
121
|
+
- ctrl+h toggles the small panel on/off.
|
|
122
|
+
- When `hideThinkingBlock` is not enabled in settings, the panel title row reminds you that think text is also visible in chat (press Ctrl+T to hide it there).
|
|
123
|
+
- Completed think blocks are separated by a `------` divider in both views — including a trailing divider after the just-finished current block.
|
|
124
|
+
|
|
125
|
+
> - 捕获模型思考内容,在输入框上方带边框面板显示最近 10 行;ctrl+o 打开更宽的全文视图(80% 宽)。
|
|
126
|
+
> - 模型思考时自动显示(思考开启时);无思考时按 `EMPTY_THINK_MODE` 隐藏(默认 `hide`)或保留最后内容(`last`)。
|
|
127
|
+
> - ctrl+h 切换小面板显示/隐藏。
|
|
128
|
+
> - 设置中 `hideThinkingBlock` 未开启时,标题行提示"聊天区也显示 think,可按 Ctrl+T 隐藏"。
|
|
129
|
+
> - 两个视图中,已完成的思考块之间用 `------` 分隔线区分,刚结束的当前块末尾也会追加一条分隔线。
|
|
130
|
+
|
|
131
|
+
## Config / 配置
|
|
132
|
+
|
|
133
|
+
At the top of `extensions/pi-think-panel.ts`:
|
|
134
|
+
|
|
135
|
+
> 修改文件顶部的常量,然后 `/reload` 生效:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// "hide" (default): panel hidden when no thinking is happening.
|
|
139
|
+
// "last": panel stays visible with the last think text.
|
|
140
|
+
const EMPTY_THINK_MODE: "last" | "hide" = "hide";
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Change it and run `/reload` in pi.
|
|
144
|
+
|
|
145
|
+
## Install / update / 安装与更新
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
cp extensions/pi-think-panel.ts ~/.pi/agent/extensions/pi-think-panel.ts
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Then `/reload` in pi (or restart). Remove by deleting the file and reloading.
|
|
152
|
+
|
|
153
|
+
> 复制到 `~/.pi/agent/extensions/` 后在 pi 里 `/reload`(或重启)生效;删除文件并
|
|
154
|
+
> reload 即可卸载。
|
|
155
|
+
|
|
156
|
+
## Development / 开发
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
npx tsc --noEmit # type-check against pi 0.83.0 types
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Known tradeoffs / 已知取舍
|
|
163
|
+
|
|
164
|
+
- ctrl+o taken over from app.tools.expand.
|
|
165
|
+
- esc and x are left untouched (normal typing / stream-abort keep working); closing the full-text overlay is another ctrl+o.
|
|
166
|
+
- Think content from replayed/loaded sessions is not captured (no events fire on replay).
|
|
167
|
+
|
|
168
|
+
> - ctrl+o 被本扩展接管(原本是 app.tools.expand)。
|
|
169
|
+
> - esc / x 完全不消费(正常打字、流式中断不受影响);关闭全文覆盖层只需再按一次 ctrl+o。
|
|
170
|
+
> - 回放/加载的旧会话不产生事件,无法捕获其中的思考内容。
|
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-think-panel — floating "think" content overlays.
|
|
3
|
+
*
|
|
4
|
+
* Captures the model's thinking from message_update events and renders:
|
|
5
|
+
* - overlay A: a top-left floating panel (PANEL_WIDTH_PCT) showing the
|
|
6
|
+
* last MAX_LINES lines of accumulated think text;
|
|
7
|
+
* - overlay B: a left-anchored full-text overlay (80% width, 90% max height)
|
|
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
|
+
*
|
|
13
|
+
* Keys (ctx.ui.onTerminalInput — ctrl+o / escape / x are reserved keys):
|
|
14
|
+
* ctrl+o toggle between overlay A (small) and overlay B (full text);
|
|
15
|
+
* thinking off → info notify
|
|
16
|
+
* ctrl+h toggle overlay A (small panel) on/off (consumed while B is closed)
|
|
17
|
+
* Closing B is another ctrl+o press (back to A); esc and x are never
|
|
18
|
+
* consumed, so typing and stream-abort keep working.
|
|
19
|
+
* Overlay A is visible while the agent is thinking and auto-hides 10s after
|
|
20
|
+
* thinking ends / the turn settles (EMPTY_THINK_MODE "hide", unless manually opened via
|
|
21
|
+
* ctrl+o). If hideThinkingBlock is not enabled in settings, the title shows
|
|
22
|
+
* a reminder that think text is also visible in chat (Ctrl+T hides it).
|
|
23
|
+
* Never writes user settings.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
ExtensionAPI,
|
|
28
|
+
ExtensionContext,
|
|
29
|
+
Theme,
|
|
30
|
+
} from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import type { OverlayHandle, TUI } from "@earendil-works/pi-tui";
|
|
32
|
+
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
33
|
+
import * as fs from "fs";
|
|
34
|
+
import * as os from "os";
|
|
35
|
+
import * as path from "path";
|
|
36
|
+
|
|
37
|
+
// Overlay A width — "90%" of terminal width — near-full width with left/right
|
|
38
|
+
// whitespace; tweak then /reload.
|
|
39
|
+
const PANEL_WIDTH_PCT = "90%";
|
|
40
|
+
// How many lines of think text overlay A shows (history tail + current block).
|
|
41
|
+
const MAX_LINES = 10;
|
|
42
|
+
// Auto-hide delay (ms) after thinking ends / the turn settles.
|
|
43
|
+
const CLOSE_DELAY_MS = 10000;
|
|
44
|
+
|
|
45
|
+
// What to do when no thinking is happening: "last" keeps overlay A visible
|
|
46
|
+
// with the last think text; "hide" (default) auto-hides it 10s after the turn
|
|
47
|
+
// settles. Change this and /reload to apply.
|
|
48
|
+
const EMPTY_THINK_MODE: "last" | "hide" = "hide";
|
|
49
|
+
|
|
50
|
+
// Layout published by pi-sidebar-panel via globalThis (same process, jiti's
|
|
51
|
+
// shared global realm — no file/module dependency between the two). When the
|
|
52
|
+
// sidebar would be drawn (enabled && terminal wide enough), overlays A/B
|
|
53
|
+
// shrink out of its column band instead of being covered by it. Absent key ⇒
|
|
54
|
+
// sidebar not installed ⇒ no adjustment.
|
|
55
|
+
const SIDEBAR_LAYOUT_KEY = "__piSidebarLayout";
|
|
56
|
+
|
|
57
|
+
interface SidebarLayout {
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
width: number; // rendered sidebar width in cols
|
|
60
|
+
minWidth: number; // terminal cols below which the sidebar is not drawn
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Module-level state — survives in-process session switches (jiti cache).
|
|
64
|
+
let thinkingEnabled = false;
|
|
65
|
+
let hideThinkingBlock = false;
|
|
66
|
+
let thinkText = ""; // current thinking block (complete text of the active message)
|
|
67
|
+
let thinkEnded = false; // current block has finished → render a trailing ------ divider
|
|
68
|
+
let history: string[] = []; // completed thinking blocks from earlier messages
|
|
69
|
+
let manuallyOpened = false; // opened via ctrl+o → auto-hide stands down
|
|
70
|
+
let manuallyHidden = false; // ctrl+h pressed → A manually hidden; ctrl+h again or a new block re-shows it
|
|
71
|
+
let fullOverlayOpen = false; // overlay B (full-text) visible
|
|
72
|
+
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
73
|
+
let tui: TUI | undefined;
|
|
74
|
+
let overlayA: OverlayHandle | undefined;
|
|
75
|
+
let overlayB: OverlayHandle | undefined;
|
|
76
|
+
let inputUnsub: (() => void) | null = null;
|
|
77
|
+
// Width currently mounted for overlays A/B (sidebar-aware); changed ⇒ remount.
|
|
78
|
+
type OverlayWidth = number | `${number}%`;
|
|
79
|
+
let mountedAWidth: OverlayWidth | undefined;
|
|
80
|
+
let mountedBWidth: OverlayWidth | undefined;
|
|
81
|
+
|
|
82
|
+
/** Layout registry read — never throws; absent ⇒ sidebar not installed. */
|
|
83
|
+
function sidebarLayout(): SidebarLayout {
|
|
84
|
+
try {
|
|
85
|
+
const v = (globalThis as Record<string, unknown>)[SIDEBAR_LAYOUT_KEY];
|
|
86
|
+
if (v && typeof v === "object") {
|
|
87
|
+
const s = v as Partial<SidebarLayout>;
|
|
88
|
+
if (typeof s.enabled === "boolean" && typeof s.width === "number") {
|
|
89
|
+
return {
|
|
90
|
+
enabled: s.enabled,
|
|
91
|
+
width: s.width,
|
|
92
|
+
minWidth: typeof s.minWidth === "number" ? s.minWidth : 0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
/* globalThis read must never throw */
|
|
98
|
+
}
|
|
99
|
+
return { enabled: false, width: 0, minWidth: 0 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Overlay A width: full PANEL_WIDTH_PCT unless the sidebar would be drawn. */
|
|
103
|
+
function overlayWidthA(): OverlayWidth {
|
|
104
|
+
const s = sidebarLayout();
|
|
105
|
+
const cols = tui?.terminal.columns ?? 0;
|
|
106
|
+
if (s.enabled && s.minWidth > 0 && cols >= s.minWidth)
|
|
107
|
+
return Math.max(20, cols - s.width - 3); // offsetX 1 + 2-col breathing room
|
|
108
|
+
return PANEL_WIDTH_PCT;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Overlay B width: "80%" unless the sidebar would be drawn. */
|
|
112
|
+
function overlayWidthB(): OverlayWidth {
|
|
113
|
+
const s = sidebarLayout();
|
|
114
|
+
const cols = tui?.terminal.columns ?? 0;
|
|
115
|
+
if (s.enabled && s.minWidth > 0 && cols >= s.minWidth)
|
|
116
|
+
return Math.max(20, cols - s.width - 4); // offsetX 2 + 2-col breathing room
|
|
117
|
+
return "80%";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Extract the complete accumulated thinking text from an assistant message. */
|
|
121
|
+
function extractThinking(message: unknown): string {
|
|
122
|
+
// AgentMessage = Message | CustomAgentMessages[...] — custom members (e.g.
|
|
123
|
+
// BashExecutionMessage) have no `content` or non-array shapes, so read defensively.
|
|
124
|
+
const content = (message as { content?: unknown } | null)?.content;
|
|
125
|
+
if (!Array.isArray(content)) return "";
|
|
126
|
+
const parts = content as Array<{ type?: string; thinking?: string }>;
|
|
127
|
+
return parts
|
|
128
|
+
.filter((c) => c.type === "thinking")
|
|
129
|
+
.map((c) => c.thinking ?? "")
|
|
130
|
+
.join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Read hideThinkingBlock from ~/.pi/agent/settings.json (never write). */
|
|
134
|
+
function readHideThinkingBlock(): boolean {
|
|
135
|
+
try {
|
|
136
|
+
const agentDir =
|
|
137
|
+
process.env.PI_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
|
|
138
|
+
const raw = JSON.parse(
|
|
139
|
+
fs.readFileSync(path.join(agentDir, "settings.json"), "utf8"),
|
|
140
|
+
) as {
|
|
141
|
+
hideThinkingBlock?: boolean;
|
|
142
|
+
};
|
|
143
|
+
return raw.hideThinkingBlock ?? false;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Full accumulated think text: completed blocks + the current block. */
|
|
150
|
+
function fullThinkText(): string {
|
|
151
|
+
// A "------" divider marks each COMPLETED block — between history entries,
|
|
152
|
+
// and (when the current block has ended) after the current block too.
|
|
153
|
+
const completed = history.join("\n------\n");
|
|
154
|
+
const current = thinkEnded && thinkText ? thinkText + "\n------" : thinkText;
|
|
155
|
+
if (!completed) return current;
|
|
156
|
+
return current ? `${completed}\n\n${current}` : completed;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* With the Kitty keyboard protocol active (flag 2), key release/repeat events
|
|
161
|
+
* are delivered too, and pi-tui's matchesKey() matches on codepoint+modifier
|
|
162
|
+
* regardless of event type — without filtering, ctrl+o would fire on press AND
|
|
163
|
+
* release and double-toggle overlay B (the "flash" bug). pi-tui's own
|
|
164
|
+
* isKeyRelease/isKeyRepeat are not exported from the package barrel, so check
|
|
165
|
+
* inline. Bracketed paste can contain these patterns (e.g. MAC addresses), so
|
|
166
|
+
* paste is treated as one event — the same guard pi-tui itself uses.
|
|
167
|
+
*/
|
|
168
|
+
function isKeyReleaseOrRepeat(data: string): boolean {
|
|
169
|
+
if (data.includes("\x1b[200~")) return false;
|
|
170
|
+
return /:(?:2|3)[u~ABCDHF]/.test(data);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Italic title line: "Thinking…" + hint, plus a dim reminder when needed. */
|
|
174
|
+
function titleLine(theme: Theme, hint: string): string {
|
|
175
|
+
const title = theme.italic(theme.fg("accent", "Thinking…") + hint);
|
|
176
|
+
if (hideThinkingBlock) return title;
|
|
177
|
+
return (
|
|
178
|
+
title + theme.italic(theme.fg("dim", " chat shows think · Ctrl+T hides"))
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Overlay A (top-center panel): last MAX_LINES lines, no inner separator. */
|
|
183
|
+
function renderTopPanel(theme: Theme, width: number): string[] {
|
|
184
|
+
const innerW = Math.max(1, width - 2);
|
|
185
|
+
const border = (s: string) => theme.fg("border", s);
|
|
186
|
+
const pad = (s: string) => truncateToWidth(s, innerW, "...", true);
|
|
187
|
+
// Think lines use the chat code-block color (mdCodeBlock) + 2-space indent,
|
|
188
|
+
// matching pi's markdown code-block idiom so think text reads as code.
|
|
189
|
+
const code = (s: string) => theme.fg("mdCodeBlock", s);
|
|
190
|
+
const rows: string[] = [];
|
|
191
|
+
rows.push(border("┌" + "─".repeat(innerW) + "┐"));
|
|
192
|
+
rows.push(
|
|
193
|
+
border("│") + pad(titleLine(theme, " ⌃O 展开 · ⌃H 隐藏")) + border("│"),
|
|
194
|
+
);
|
|
195
|
+
const text = fullThinkText();
|
|
196
|
+
const lines = text ? text.split(/\r?\n/).slice(-MAX_LINES) : [];
|
|
197
|
+
if (lines.length === 0) {
|
|
198
|
+
rows.push(
|
|
199
|
+
border("│") + pad(theme.fg("dim", " (no thinking yet)")) + border("│"),
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
for (const l of lines)
|
|
203
|
+
rows.push(
|
|
204
|
+
border("│") +
|
|
205
|
+
pad(l === "------" ? theme.fg("dim", " ------") : code(" " + l)) +
|
|
206
|
+
border("│"),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
rows.push(border("└" + "─".repeat(innerW) + "┘"));
|
|
210
|
+
return rows;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Overlay B (centered full-text): every line, capped so the hint stays visible. */
|
|
214
|
+
function renderFullPanel(theme: Theme, width: number): string[] {
|
|
215
|
+
const innerW = Math.max(1, width - 2);
|
|
216
|
+
const border = (s: string) => theme.fg("border", s);
|
|
217
|
+
const pad = (s: string) => truncateToWidth(s, innerW, "...", true);
|
|
218
|
+
// Think lines use the chat code-block color (mdCodeBlock) + 2-space indent,
|
|
219
|
+
// matching pi's markdown code-block idiom so think text reads as code.
|
|
220
|
+
const code = (s: string) => theme.fg("mdCodeBlock", s);
|
|
221
|
+
const rows: string[] = [];
|
|
222
|
+
rows.push(border("┌" + "─".repeat(innerW) + "┐"));
|
|
223
|
+
rows.push(border("│") + pad(titleLine(theme, " ⌃O 收起")) + border("│"));
|
|
224
|
+
const text = fullThinkText();
|
|
225
|
+
const lines = text ? text.split(/\r?\n/) : [];
|
|
226
|
+
if (lines.length === 0) {
|
|
227
|
+
rows.push(
|
|
228
|
+
border("│") + pad(theme.fg("dim", " (no thinking yet)")) + border("│"),
|
|
229
|
+
);
|
|
230
|
+
} else {
|
|
231
|
+
// maxHeight is 90% of terminal rows — cap the body so the hint (and the
|
|
232
|
+
// bottom border) are not hard-truncated by the TUI. Show the TAIL so the
|
|
233
|
+
// view follows the newest content (a live "details" view — auto-scrolls
|
|
234
|
+
// down as new think lines stream in, same behavior as overlay A).
|
|
235
|
+
const termRows = tui?.terminal.rows ?? 40;
|
|
236
|
+
const maxBody = Math.max(2, Math.floor(termRows * 0.9) - 4);
|
|
237
|
+
const shown = lines.length > maxBody ? lines.slice(-maxBody) : lines;
|
|
238
|
+
if (lines.length > maxBody) {
|
|
239
|
+
rows.push(
|
|
240
|
+
border("│") + pad(theme.fg("dim", " …(更早内容已省略)")) + border("│"),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
for (const l of shown)
|
|
244
|
+
rows.push(
|
|
245
|
+
border("│") +
|
|
246
|
+
pad(l === "------" ? theme.fg("dim", " ------") : code(" " + l)) +
|
|
247
|
+
border("│"),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
rows.push(border("└" + "─".repeat(innerW) + "┘"));
|
|
251
|
+
return rows;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export default function (pi: ExtensionAPI): void {
|
|
255
|
+
// Streaming flag — guards escape consumption (never block stream-abort).
|
|
256
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
257
|
+
if (ctx?.mode !== "tui") return;
|
|
258
|
+
});
|
|
259
|
+
// Set (or reset) the auto-hide timer; new think activity cancels it.
|
|
260
|
+
function armCloseTimer(): void {
|
|
261
|
+
if (closeTimer !== undefined) clearTimeout(closeTimer);
|
|
262
|
+
closeTimer = setTimeout(() => {
|
|
263
|
+
closeTimer = undefined;
|
|
264
|
+
if (EMPTY_THINK_MODE === "hide" && !manuallyOpened) {
|
|
265
|
+
overlayA?.setHidden(true);
|
|
266
|
+
}
|
|
267
|
+
}, CLOSE_DELAY_MS);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
271
|
+
if (ctx?.mode !== "tui") return;
|
|
272
|
+
// Hide once the turn has been idle for CLOSE_DELAY_MS; any new think
|
|
273
|
+
// activity cancels the timer and re-shows the panel.
|
|
274
|
+
armCloseTimer();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Keep thinking-enabled in sync with every level change (top-level subscription).
|
|
278
|
+
pi.on("thinking_level_select", (event, ctx) => {
|
|
279
|
+
if (ctx?.mode !== "tui") return;
|
|
280
|
+
thinkingEnabled = event.level !== "off";
|
|
281
|
+
if (!thinkingEnabled) {
|
|
282
|
+
// Unmount + reset.
|
|
283
|
+
if (closeTimer !== undefined) {
|
|
284
|
+
clearTimeout(closeTimer);
|
|
285
|
+
closeTimer = undefined;
|
|
286
|
+
}
|
|
287
|
+
overlayA?.hide();
|
|
288
|
+
overlayB?.hide();
|
|
289
|
+
overlayA = undefined;
|
|
290
|
+
overlayB = undefined;
|
|
291
|
+
fullOverlayOpen = false;
|
|
292
|
+
manuallyOpened = false;
|
|
293
|
+
manuallyHidden = false;
|
|
294
|
+
thinkEnded = false;
|
|
295
|
+
} else if (overlayA === undefined) {
|
|
296
|
+
mountOverlays(ctx);
|
|
297
|
+
}
|
|
298
|
+
tui?.requestRender();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Capture: message_update always carries the COMPLETE thinking text.
|
|
302
|
+
pi.on("message_update", (event, ctx) => {
|
|
303
|
+
if (ctx?.mode !== "tui") return;
|
|
304
|
+
if (!thinkingEnabled) return;
|
|
305
|
+
const t = event.assistantMessageEvent.type;
|
|
306
|
+
if (!t.startsWith("thinking_")) return;
|
|
307
|
+
if (t === "thinking_start") {
|
|
308
|
+
// A new block is starting — rotate the finished one into history.
|
|
309
|
+
if (thinkText) {
|
|
310
|
+
history.push(thinkText);
|
|
311
|
+
thinkText = "";
|
|
312
|
+
}
|
|
313
|
+
thinkEnded = false;
|
|
314
|
+
manuallyHidden = false; // fresh block → auto-show resumes
|
|
315
|
+
} else {
|
|
316
|
+
thinkText = extractThinking(event.message);
|
|
317
|
+
thinkEnded = t === "thinking_end";
|
|
318
|
+
}
|
|
319
|
+
if (t === "thinking_end") {
|
|
320
|
+
// Thinking finished — arm the auto-hide timer (measured from the actual
|
|
321
|
+
// end, robust to event order around agent_settled).
|
|
322
|
+
if (closeTimer === undefined) armCloseTimer();
|
|
323
|
+
} else {
|
|
324
|
+
// thinking_start / thinking_delta — active thinking cancels a pending
|
|
325
|
+
// auto-hide.
|
|
326
|
+
if (closeTimer !== undefined) {
|
|
327
|
+
clearTimeout(closeTimer);
|
|
328
|
+
closeTimer = undefined;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Don't re-show the small panel behind an open full-text overlay or a
|
|
332
|
+
// user-requested hide (ctrl+h).
|
|
333
|
+
if (!fullOverlayOpen && !manuallyHidden) overlayA?.setHidden(false);
|
|
334
|
+
// Sidebar toggled or terminal resized? Re-mount with the corrected
|
|
335
|
+
// width (cheap no-op unless the width actually changed).
|
|
336
|
+
syncOverlayWidths(ctx);
|
|
337
|
+
tui?.requestRender();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Reset module state + (re)register the key listener on each session.
|
|
341
|
+
pi.on("session_start", (_event, ctx) => {
|
|
342
|
+
if (ctx?.mode !== "tui") return;
|
|
343
|
+
|
|
344
|
+
// Reset module state that survived the previous in-process session.
|
|
345
|
+
if (closeTimer !== undefined) {
|
|
346
|
+
clearTimeout(closeTimer);
|
|
347
|
+
closeTimer = undefined;
|
|
348
|
+
}
|
|
349
|
+
tui = undefined;
|
|
350
|
+
overlayA = undefined;
|
|
351
|
+
overlayB = undefined;
|
|
352
|
+
mountedAWidth = undefined;
|
|
353
|
+
mountedBWidth = undefined;
|
|
354
|
+
fullOverlayOpen = false;
|
|
355
|
+
manuallyOpened = false;
|
|
356
|
+
manuallyHidden = false;
|
|
357
|
+
thinkText = "";
|
|
358
|
+
thinkEnded = false;
|
|
359
|
+
history = [];
|
|
360
|
+
hideThinkingBlock = readHideThinkingBlock();
|
|
361
|
+
thinkingEnabled = (ctx.thinkingLevel ?? "off") !== "off";
|
|
362
|
+
|
|
363
|
+
// (Re)register the key listener; tear down the previous session's one.
|
|
364
|
+
if (inputUnsub) {
|
|
365
|
+
inputUnsub();
|
|
366
|
+
inputUnsub = null;
|
|
367
|
+
}
|
|
368
|
+
inputUnsub = ctx.ui.onTerminalInput((data) => {
|
|
369
|
+
// Kitty-protocol release/repeat events match the same keys — ignore them
|
|
370
|
+
// so each keypress fires exactly once (fixes the ctrl+o double-toggle).
|
|
371
|
+
if (isKeyReleaseOrRepeat(data)) return undefined;
|
|
372
|
+
if (matchesKey(data, "ctrl+o")) {
|
|
373
|
+
syncOverlayWidths(ctx); // sidebar may have toggled while idle
|
|
374
|
+
if (!thinkingEnabled) {
|
|
375
|
+
ctx.ui.notify("Think panel: thinking is off", "info");
|
|
376
|
+
return { consume: true };
|
|
377
|
+
}
|
|
378
|
+
if (fullOverlayOpen) {
|
|
379
|
+
overlayB?.setHidden(true);
|
|
380
|
+
fullOverlayOpen = false;
|
|
381
|
+
// Collapse back onto the small panel (unless the user hid A).
|
|
382
|
+
if (!manuallyHidden) overlayA?.setHidden(false);
|
|
383
|
+
} else {
|
|
384
|
+
overlayB?.setHidden(false);
|
|
385
|
+
fullOverlayOpen = true;
|
|
386
|
+
manuallyOpened = true; // opening counts as manual — auto-hide stands down
|
|
387
|
+
overlayA?.setHidden(true); // hide A so it doesn't show through behind B
|
|
388
|
+
}
|
|
389
|
+
return { consume: true };
|
|
390
|
+
}
|
|
391
|
+
if (
|
|
392
|
+
matchesKey(data, "ctrl+h") &&
|
|
393
|
+
!fullOverlayOpen &&
|
|
394
|
+
overlayA !== undefined
|
|
395
|
+
) {
|
|
396
|
+
// Toggle the small panel: visible → hide it, hidden → bring it back.
|
|
397
|
+
if (overlayA.isHidden()) {
|
|
398
|
+
overlayA.setHidden(false);
|
|
399
|
+
manuallyHidden = false;
|
|
400
|
+
} else {
|
|
401
|
+
overlayA.setHidden(true);
|
|
402
|
+
manuallyHidden = true;
|
|
403
|
+
}
|
|
404
|
+
return { consume: true };
|
|
405
|
+
}
|
|
406
|
+
return undefined;
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Mount the overlays once per session (visibility toggled afterwards).
|
|
410
|
+
if (thinkingEnabled) mountOverlays(ctx);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
414
|
+
if (ctx?.mode !== "tui") return;
|
|
415
|
+
if (inputUnsub) {
|
|
416
|
+
inputUnsub();
|
|
417
|
+
inputUnsub = null;
|
|
418
|
+
}
|
|
419
|
+
if (closeTimer !== undefined) {
|
|
420
|
+
clearTimeout(closeTimer);
|
|
421
|
+
closeTimer = undefined;
|
|
422
|
+
}
|
|
423
|
+
overlayA?.setHidden(true);
|
|
424
|
+
overlayB?.setHidden(true);
|
|
425
|
+
overlayA = undefined;
|
|
426
|
+
overlayB = undefined;
|
|
427
|
+
fullOverlayOpen = false;
|
|
428
|
+
tui = undefined;
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Mount both overlays. ctx.ui.custom resolves only when done() is called,
|
|
432
|
+
// so do NOT await it — a persistent overlay never calls done().
|
|
433
|
+
function mountOverlays(ctx: ExtensionContext): void {
|
|
434
|
+
mountOverlayA(ctx);
|
|
435
|
+
mountOverlayB(ctx);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function mountOverlayA(ctx: ExtensionContext): void {
|
|
439
|
+
mountedAWidth = overlayWidthA();
|
|
440
|
+
void ctx.ui.custom(
|
|
441
|
+
(t, theme) => {
|
|
442
|
+
tui = t;
|
|
443
|
+
// Terminal cols are only known once the TUI ref is captured —
|
|
444
|
+
// re-check the sidebar-aware width a tick after mounting.
|
|
445
|
+
queueMicrotask(() => syncOverlayWidths(ctx));
|
|
446
|
+
return {
|
|
447
|
+
dispose() {},
|
|
448
|
+
invalidate() {
|
|
449
|
+
t.requestRender();
|
|
450
|
+
},
|
|
451
|
+
render(width: number): string[] {
|
|
452
|
+
return renderTopPanel(theme, width);
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
overlay: true,
|
|
458
|
+
overlayOptions: {
|
|
459
|
+
anchor: "top-left",
|
|
460
|
+
offsetX: 1,
|
|
461
|
+
offsetY: 1,
|
|
462
|
+
width: mountedAWidth,
|
|
463
|
+
nonCapturing: true,
|
|
464
|
+
},
|
|
465
|
+
onHandle: (h) => {
|
|
466
|
+
overlayA = h;
|
|
467
|
+
if (EMPTY_THINK_MODE === "hide") h.setHidden(true);
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function mountOverlayB(ctx: ExtensionContext): void {
|
|
474
|
+
mountedBWidth = overlayWidthB();
|
|
475
|
+
void ctx.ui.custom(
|
|
476
|
+
(t, theme) => {
|
|
477
|
+
tui = t;
|
|
478
|
+
queueMicrotask(() => syncOverlayWidths(ctx));
|
|
479
|
+
return {
|
|
480
|
+
dispose() {},
|
|
481
|
+
invalidate() {
|
|
482
|
+
t.requestRender();
|
|
483
|
+
},
|
|
484
|
+
render(width: number): string[] {
|
|
485
|
+
return renderFullPanel(theme, width);
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
overlay: true,
|
|
491
|
+
overlayOptions: {
|
|
492
|
+
anchor: "left-center",
|
|
493
|
+
offsetX: 2,
|
|
494
|
+
width: mountedBWidth,
|
|
495
|
+
maxHeight: "90%",
|
|
496
|
+
margin: { top: 1 },
|
|
497
|
+
nonCapturing: true,
|
|
498
|
+
},
|
|
499
|
+
onHandle: (h) => {
|
|
500
|
+
overlayB = h;
|
|
501
|
+
h.setHidden(true);
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Re-mount overlays A/B when the sidebar-aware width changed. */
|
|
508
|
+
function syncOverlayWidths(ctx: ExtensionContext): void {
|
|
509
|
+
const wa = overlayWidthA();
|
|
510
|
+
if (wa !== mountedAWidth) remountOverlayA(ctx, wa);
|
|
511
|
+
const wb = overlayWidthB();
|
|
512
|
+
if (wb !== mountedBWidth) remountOverlayB(ctx, wb);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function remountOverlayA(ctx: ExtensionContext, width: OverlayWidth): void {
|
|
516
|
+
const old = overlayA;
|
|
517
|
+
const wasVisible = old?.isHidden() === false;
|
|
518
|
+
old?.hide();
|
|
519
|
+
overlayA = undefined;
|
|
520
|
+
mountedAWidth = width;
|
|
521
|
+
void ctx.ui.custom(
|
|
522
|
+
(t, theme) => {
|
|
523
|
+
tui = t;
|
|
524
|
+
queueMicrotask(() => syncOverlayWidths(ctx));
|
|
525
|
+
return {
|
|
526
|
+
dispose() {},
|
|
527
|
+
invalidate() {
|
|
528
|
+
t.requestRender();
|
|
529
|
+
},
|
|
530
|
+
render(width: number): string[] {
|
|
531
|
+
return renderTopPanel(theme, width);
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
overlay: true,
|
|
537
|
+
overlayOptions: {
|
|
538
|
+
anchor: "top-left",
|
|
539
|
+
offsetX: 1,
|
|
540
|
+
offsetY: 1,
|
|
541
|
+
width,
|
|
542
|
+
nonCapturing: true,
|
|
543
|
+
},
|
|
544
|
+
onHandle: (h) => {
|
|
545
|
+
overlayA = h;
|
|
546
|
+
// Preserve visibility across the remount: mid-thinking stays
|
|
547
|
+
// visible; idle ("hide" mode) and open-B states stay hidden.
|
|
548
|
+
h.setHidden(fullOverlayOpen || !(wasVisible && thinkingEnabled));
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function remountOverlayB(ctx: ExtensionContext, width: OverlayWidth): void {
|
|
555
|
+
const old = overlayB;
|
|
556
|
+
old?.hide();
|
|
557
|
+
overlayB = undefined;
|
|
558
|
+
mountedBWidth = width;
|
|
559
|
+
void ctx.ui.custom(
|
|
560
|
+
(t, theme) => {
|
|
561
|
+
tui = t;
|
|
562
|
+
queueMicrotask(() => syncOverlayWidths(ctx));
|
|
563
|
+
return {
|
|
564
|
+
dispose() {},
|
|
565
|
+
invalidate() {
|
|
566
|
+
t.requestRender();
|
|
567
|
+
},
|
|
568
|
+
render(width: number): string[] {
|
|
569
|
+
return renderFullPanel(theme, width);
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
overlay: true,
|
|
575
|
+
overlayOptions: {
|
|
576
|
+
anchor: "left-center",
|
|
577
|
+
offsetX: 2,
|
|
578
|
+
width,
|
|
579
|
+
maxHeight: "90%",
|
|
580
|
+
margin: { top: 1 },
|
|
581
|
+
nonCapturing: true,
|
|
582
|
+
},
|
|
583
|
+
onHandle: (h) => {
|
|
584
|
+
overlayB = h;
|
|
585
|
+
h.setHidden(!fullOverlayOpen); // preserve open state
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aiwayds/pi-think-panel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "pi-coding-agent extension — toggleable floating 'think' content panel showing live reasoning, ctrl+o for full-text view",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"think",
|
|
9
|
+
"reasoning",
|
|
10
|
+
"panel",
|
|
11
|
+
"tui"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "fan56",
|
|
15
|
+
"main": "extensions/pi-think-panel.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"extensions",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/fan56/pi-think-panel.git"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
30
|
+
"@earendil-works/pi-tui": "*"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@earendil-works/pi-tui": "*",
|
|
34
|
+
"jiti": "^2.4.0",
|
|
35
|
+
"typescript": "^5.6.0",
|
|
36
|
+
"@types/node": "^22.0.0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
}
|
|
44
|
+
}
|