@aiwayds/pi-think-panel 0.1.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 CHANGED
@@ -1,12 +1,10 @@
1
1
  # pi-think-panel
2
2
 
3
- Toggleable "think" content panel for the pi coding agent TUI.
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
- > 可在 pi 编码代理 TUI 中切换显示的"思考内容"面板。
6
-
7
- ## Demo / 演示
8
-
9
- ![pi-think-panel demo](docs/think-panel-demo.gif)
6
+ > pi 编码代理 TUI 的思考视口 widget——像 todo widget 一样挂在输入框上方,
7
+ > 实时滚动显示模型推理内容。
10
8
 
11
9
  ## Why / 为什么做这个扩展
12
10
 
@@ -19,152 +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 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.
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
- Small panel (overlay A) floats top-left while the model is thinking: last 10
38
- lines, auto-following the newest content, chat stays clean:
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.
39
36
 
40
- > 小面板(overlay A):模型思考时浮在左上角,显示最近 10 行,跟随最新内容,
41
- > 聊天区保持干净。
37
+ > widget 位于输入框上方的扩展 widget 槽位(与 todo widget 同槽位),显示最近
38
+ > N 行思考内容;超过 N 行向上滚动,最新内容始终可见。没有思考时 0 行不占位。
42
39
 
43
40
  ```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
- └──────────────────────────────────────────────────────────────┘
41
+ ┌──────────────────────────────────────────────────────────┐
42
+ chat history
43
+
44
+ ├──────────────────────────────────────────────────────────┤
45
+ 🧠Thinking: 先确认 widget 槽位的宽度约束,再决定 wrap 策略 widget (N lines,
46
+ 终端宽度变化时按新宽度重排,始终显示窗口尾部最新内容 │ scrolls up)
47
+ ├──────────────────────────────────────────────────────────┤
48
+ editor input
49
+ └──────────────────────────────────────────────────────────┘
55
50
  ```
56
51
 
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
- ```
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.
76
56
 
77
- With `pi-sidebar-panel` installed **and** enabled (terminal ≥ 100 cols), both
78
- overlays shrink to leave the sidebar's right-hand band free:
57
+ > 默认 **1 行**:`🧠Thinking: <最新思考>`,超出终端宽度尾部截断。
58
+ > **3 / 5 / 7 行**显示更多最新推理;首行保留 `🧠Thinking:` 前缀,其余为纯内容行。
79
59
 
80
- > 安装了 `pi-sidebar-panel` 且已开启(终端 ≥ 100 列)时,两个覆盖层自动收窄,
81
- > 让出右侧 sidebar 区域。
60
+ ## Command / 命令
82
61
 
83
- ```text
84
- ┌───────────────────────────────────────────────┬────────────┐
85
- ╭─ Thinking… ─────────────╮ │ sidebar │
86
- │ reasoning line … │ │ todos │
87
- ╰─────────────────────────╯ │ agents │
88
- │ └────────────┘
89
- │ chat / editor
90
- └─────────────────────────────────────────────────────────────
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
91
67
  ```
92
68
 
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 / 快捷键
69
+ The setting is persisted to `~/.pi/agent/think-panel.json` as
70
+ `{"lines": N}` (`0` = off) and survives restarts.
101
71
 
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) |
72
+ > 设置持久化到 `~/.pi/agent/think-panel.json`(`{"lines": N}`,`0` = 关),
73
+ > 重启后保持。
106
74
 
107
- | 按键 | 行为 |
108
- | --- | --- |
109
- | ctrl+o | 小面板与大窗口(全文视图)之间切换(思考关闭时 → 提示信息) |
110
- | ctrl+h | 切换小面板显示/隐藏(保持状态,直到再次切换或下一个思考块开始) |
75
+ ## What it does / 功能
111
76
 
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.
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
+ > - 同一回合内多个思考块之间用空行分隔。
113
91
 
114
- > 说明:ctrl+o 原本是工具面板(app.tools.expand)的保留键,本扩展接管了它 ——
115
- > 如需保留可在 keybindings 配置里重新绑定。
92
+ ## Config / 配置
116
93
 
117
- ## What it does / 功能
94
+ `~/.pi/agent/think-panel.json`:
118
95
 
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.
96
+ ```json
97
+ { "lines": 1 }
98
+ ```
124
99
 
125
- > - 捕获模型思考内容,在输入框上方带边框面板显示最近 10 行;ctrl+o 打开更宽的全文视图(80% 宽)。
126
- > - 模型思考时自动显示(思考开启时);无思考时按 `EMPTY_THINK_MODE` 隐藏(默认 `hide`)或保留最后内容(`last`)。
127
- > - ctrl+h 切换小面板显示/隐藏。
128
- > - 设置中 `hideThinkingBlock` 未开启时,标题行提示"聊天区也显示 think,可按 Ctrl+T 隐藏"。
129
- > - 两个视图中,已完成的思考块之间用 `------` 分隔线区分,刚结束的当前块末尾也会追加一条分隔线。
100
+ Valid values: `1 | 3 | 5 | 7 | 0` (`0` = off). Prefer the `/think-panel`
101
+ command it validates and writes the same file.
130
102
 
131
- ## Config / 配置
103
+ > 合法值 `1 | 3 | 5 | 7 | 0`(`0` = 关)。推荐用 `/think-panel` 命令切换,
104
+ > 会校验参数并写同一文件。
132
105
 
133
- At the top of `extensions/pi-think-panel.ts`:
106
+ ## Install / update / 安装与更新
134
107
 
135
- > 修改文件顶部的常量,然后 `/reload` 生效:
108
+ From npm (recommended / 推荐):
136
109
 
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";
110
+ ```bash
111
+ pi install npm:@aiwayds/pi-think-panel
141
112
  ```
142
113
 
143
- Change it and run `/reload` in pi.
144
-
145
- ## Install / update / 安装与更新
114
+ Or copy the single file (same loader, no build step / 单文件直载,无构建):
146
115
 
147
116
  ```bash
148
117
  cp extensions/pi-think-panel.ts ~/.pi/agent/extensions/pi-think-panel.ts
149
118
  ```
150
119
 
151
- Then `/reload` in pi (or restart). Remove by deleting the file and reloading.
120
+ Then `/reload` in pi (or restart). Remove by deleting the entry/file and
121
+ reloading.
152
122
 
153
- > 复制到 `~/.pi/agent/extensions/` 后在 pi 里 `/reload`(或重启)生效;删除文件并
154
- > reload 即可卸载。
123
+ > pi 里 `/reload`(或重启)生效;删除对应包/文件并 reload 即可卸载。
155
124
 
156
125
  ## Development / 开发
157
126
 
158
127
  ```bash
159
- npx tsc --noEmit # type-check against pi 0.83.0 types
128
+ npm run typecheck # type-check against installed pi types
129
+ npm run smoke # jiti-loads the entry, verifies the factory shape
160
130
  ```
161
131
 
162
132
  ## Known tradeoffs / 已知取舍
163
133
 
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).
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.
167
140
 
168
- > - ctrl+o 被本扩展接管(原本是 app.tools.expand)。
169
- > - esc / x 完全不消费(正常打字、流式中断不受影响);关闭全文覆盖层只需再按一次 ctrl+o。
170
141
  > - 回放/加载的旧会话不产生事件,无法捕获其中的思考内容。
142
+ > - 隐藏聊天区原生思考块请配合 pi 自带的 `hideThinkingBlock` 设置(推荐同开);
143
+ > 本扩展绝不改写用户设置。
144
+ > - widget 只承载思考内容。原生工具渲染刻意不动——pi 没有官方 API 可以关掉它。
@@ -1,26 +1,27 @@
1
1
  /**
2
- * pi-think-panel — floating "think" content overlays.
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
- * - 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.
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.
12
8
  *
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.
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.
24
25
  */
25
26
 
26
27
  import type {
@@ -28,99 +29,71 @@ import type {
28
29
  ExtensionContext,
29
30
  Theme,
30
31
  } 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";
32
+ import type { TUI } from "@earendil-works/pi-tui";
33
+ import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
33
34
  import * as fs from "fs";
34
35
  import * as os from "os";
35
36
  import * as path from "path";
36
37
 
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";
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
49
45
 
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";
46
+ // ── Config (~/.pi/agent/think-panel.json) ─────────────────────────────────
56
47
 
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
48
+ function agentDir(): string {
49
+ return process.env.PI_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
61
50
  }
62
51
 
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;
52
+ function configPath(): string {
53
+ return path.join(agentDir(), "think-panel.json");
54
+ }
81
55
 
82
- /** Layout registry read — never throws; absent ⇒ sidebar not installed. */
83
- function sidebarLayout(): SidebarLayout {
56
+ function loadLines(): LinesConfig {
84
57
  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
- }
58
+ const raw = JSON.parse(fs.readFileSync(configPath(), "utf8")) as {
59
+ lines?: unknown;
60
+ };
61
+ return normalizeLines(raw.lines);
96
62
  } catch {
97
- /* globalThis read must never throw */
63
+ return 1;
98
64
  }
99
- return { enabled: false, width: 0, minWidth: 0 };
100
65
  }
101
66
 
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;
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;
109
71
  }
110
72
 
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%";
73
+ function saveLines(lines: LinesConfig): void {
74
+ try {
75
+ fs.writeFileSync(
76
+ configPath(),
77
+ JSON.stringify({ lines }, null, 2) + "\n",
78
+ "utf8",
79
+ );
80
+ } catch {
81
+ /* persistence is best-effort; the in-session value still applies */
82
+ }
118
83
  }
119
84
 
85
+ // ── Module state (survives in-process session switches via jiti cache) ────
86
+
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
92
+
120
93
  /** Extract the complete accumulated thinking text from an assistant message. */
121
94
  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.
95
+ // AgentMessage is a union whose custom members have no `content` array —
96
+ // read defensively.
124
97
  const content = (message as { content?: unknown } | null)?.content;
125
98
  if (!Array.isArray(content)) return "";
126
99
  const parts = content as Array<{ type?: string; thinking?: string }>;
@@ -130,461 +103,153 @@ function extractThinking(message: unknown): string {
130
103
  .join("\n");
131
104
  }
132
105
 
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
- }
106
+ function resetViewport(): void {
107
+ blocks = [];
108
+ blockText = "";
109
+ thinkActive = false;
147
110
  }
148
111
 
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;
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;
157
117
  }
158
118
 
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
- }
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 [];
172
124
 
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
- }
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;
128
+
129
+ const wrapped = wrapTextWithAnsi(tail, Math.max(1, width));
130
+ const window = wrapped.slice(-lines);
131
+ if (window.length === 0) return [];
181
132
 
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);
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.
190
135
  const rows: string[] = [];
191
- rows.push(border("┌" + "─".repeat(innerW) + "┐"));
192
136
  rows.push(
193
- border("│") + pad(titleLine(theme, " ⌃O 展开 · ⌃H 隐藏")) + border("│"),
137
+ truncateToWidth(
138
+ theme.fg("accent", PREFIX) + theme.fg("mdCodeBlock", window[0]),
139
+ width,
140
+ ),
194
141
  );
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
- );
142
+ for (let i = 1; i < window.length; i++) {
143
+ rows.push(truncateToWidth(theme.fg("mdCodeBlock", window[i]), width));
208
144
  }
209
- rows.push(border("└" + "─".repeat(innerW) + "┘"));
210
145
  return rows;
211
146
  }
212
147
 
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
- }
148
+ // ── Extension entry ───────────────────────────────────────────────────────
253
149
 
254
150
  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) => {
151
+ pi.on("session_start", (_event, ctx) => {
279
152
  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();
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
+ });
299
172
  });
300
173
 
301
174
  // Capture: message_update always carries the COMPLETE thinking text.
302
175
  pi.on("message_update", (event, ctx) => {
303
176
  if (ctx?.mode !== "tui") return;
304
- if (!thinkingEnabled) return;
305
177
  const t = event.assistantMessageEvent.type;
306
178
  if (!t.startsWith("thinking_")) return;
307
179
  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 = "";
180
+ // A new block is starting — rotate the finished one into blocks.
181
+ if (blockText) {
182
+ blocks.push(blockText);
183
+ blockText = "";
312
184
  }
313
- thinkEnded = false;
314
- manuallyHidden = false; // fresh block → auto-show resumes
315
185
  } 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;
186
+ blockText = extractThinking(event.message);
187
+ if (blockText.length > MAX_RETAINED_CHARS) {
188
+ blockText = blockText.slice(-MAX_RETAINED_CHARS);
329
189
  }
330
190
  }
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);
191
+ thinkActive = true;
337
192
  tui?.requestRender();
338
193
  });
339
194
 
340
- // Reset module state + (re)register the key listener on each session.
341
- pi.on("session_start", (_event, ctx) => {
195
+ // Turn fully settled (stream done / aborted) viewport goes away.
196
+ pi.on("agent_settled", (_event, ctx) => {
342
197
  if (ctx?.mode !== "tui") return;
198
+ resetViewport();
199
+ tui?.requestRender();
200
+ });
343
201
 
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;
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();
367
208
  }
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
209
  });
412
210
 
413
211
  pi.on("session_shutdown", (_event, ctx) => {
414
212
  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;
213
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
214
+ resetViewport();
428
215
  tui = undefined;
429
216
  });
430
217
 
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
- }
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
+ });
590
255
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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",
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",
@@ -13,6 +13,11 @@
13
13
  "license": "MIT",
14
14
  "author": "fan56",
15
15
  "main": "extensions/pi-think-panel.ts",
16
+ "pi": {
17
+ "extensions": [
18
+ "extensions/pi-think-panel.ts"
19
+ ]
20
+ },
16
21
  "files": [
17
22
  "extensions",
18
23
  "README.md",
@@ -30,12 +35,14 @@
30
35
  "@earendil-works/pi-tui": "*"
31
36
  },
32
37
  "devDependencies": {
38
+ "@earendil-works/pi-coding-agent": "*",
33
39
  "@earendil-works/pi-tui": "*",
40
+ "@types/node": "^22.0.0",
34
41
  "jiti": "^2.4.0",
35
- "typescript": "^5.6.0",
36
- "@types/node": "^22.0.0"
42
+ "typescript": "^5.6.0"
37
43
  },
38
44
  "scripts": {
45
+ "smoke": "node smoke-test.cjs",
39
46
  "typecheck": "tsc --noEmit"
40
47
  },
41
48
  "engines": {