pi-open-tui 0.3.1 → 0.3.3
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 +21 -0
- package/README.zh-CN.md +21 -0
- package/extensions/open-tui/config.ts +18 -0
- package/extensions/open-tui/editor.ts +74 -18
- package/extensions/open-tui/index.ts +134 -25
- package/extensions/open-tui/peek.ts +168 -0
- package/extensions/open-tui/settings-command.ts +44 -4
- package/extensions/open-tui/utils.ts +12 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ A polished terminal interface for the [Pi](https://pi.dev) coding agent. It brin
|
|
|
13
13
|
- **Framed editor** with block, bar, and underline cursor styles
|
|
14
14
|
- **Project awareness** for 50+ runtimes and detailed Git states, including ahead/behind, staged, modified, untracked, stashed, and detached HEAD
|
|
15
15
|
- **Turn telemetry** for TPS, time to first token (TTFT), duration, stalls, tokens, and list-price rate
|
|
16
|
+
- **Thinking peek**: an inline ticker replaces Pi's hidden `Thinking...` label with the tail of the model's reasoning while it works
|
|
16
17
|
- **Interactive settings** through `/open-tui`, available in English and Simplified Chinese
|
|
17
18
|
- **Version-guarded Pi compatibility shim**: fullscreen wheel speed falls back to Pi's default if its runtime support changes
|
|
18
19
|
|
|
@@ -83,6 +84,9 @@ Run `/open-tui` to open the settings dialog. It provides **General**, **Appearan
|
|
|
83
84
|
"tokens": true,
|
|
84
85
|
"stalls": true,
|
|
85
86
|
"cost": true
|
|
87
|
+
},
|
|
88
|
+
"thinkingPeek": {
|
|
89
|
+
"lines": 1
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
```
|
|
@@ -97,6 +101,7 @@ Key options:
|
|
|
97
101
|
| `icons.mode` | `auto`, `nerd`, `ascii` | Controls footer and telemetry icons |
|
|
98
102
|
| `footerSegments` | Boolean flags | Shows or hides individual footer data |
|
|
99
103
|
| `telemetry` | Boolean flags | Enables telemetry and its individual measurements |
|
|
104
|
+
| `thinkingPeek.lines` | `0`, `1`, `2` | Off, one-line, or two-line hidden thinking preview |
|
|
100
105
|
|
|
101
106
|
`sessionName` appears only when the session has a name. `gitCommit` shows the short hash and tag in detached HEAD state. Disabling `extensionStatuses` hides the entire extension status line, including MCP status.
|
|
102
107
|
|
|
@@ -114,6 +119,22 @@ TPS is calculated from all provider-reported assistant output tokens divided by
|
|
|
114
119
|
|
|
115
120
|
The `$ / M` value is the model's list-price rate from `usage.cost.total`, not the cumulative session cost shown in the footer. Every telemetry field can be toggled from the **Telemetry** tab.
|
|
116
121
|
|
|
122
|
+
## Thinking peek
|
|
123
|
+
|
|
124
|
+
When Pi's **Hide thinking** setting is enabled, pi-open-tui shows a compact ticker in the native hidden thinking block's `Thinking...` position. The `/open-tui` setting offers three modes: **Off**, **1 line**, and **2 lines**.
|
|
125
|
+
|
|
126
|
+
- while the model is reasoning, the current thinking tail streams by with a spinner (`~ think ⠋ …`);
|
|
127
|
+
- when the answer starts, it settles on a check mark (`~ think ✓`);
|
|
128
|
+
- in 2-line mode, the previous and latest thinking lines share the same text indentation; if the latest line overflows, both rows follow its newest tail as tokens arrive instead of retaining the previous line;
|
|
129
|
+
- after the task settles, the native `Thinking...` label is restored.
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
~ think ⠋ previous thought
|
|
133
|
+
latest thought
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The ticker appears only once the model actually streams reasoning, so non-reasoning models never show it. Pi's own Hide thinking toggle controls visibility; changing it takes effect immediately. Each row is truncated by *visible* width, so CJK-wide thinking text cannot overflow. Configure it from **General → Thinking peek** in `/open-tui`, or via `thinkingPeek.lines` in `open-tui.json`.
|
|
137
|
+
|
|
117
138
|
## Local development
|
|
118
139
|
|
|
119
140
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
- **带边框的编辑器**:支持块状、竖线和下划线三种光标样式
|
|
14
14
|
- **项目环境感知**:识别 50 多种运行环境,并展示 ahead/behind、已暂存、已修改、未跟踪、stash 和 detached HEAD 等 Git 状态
|
|
15
15
|
- **单轮遥测**:展示 TPS、首 Token 延迟(TTFT)、耗时、停顿、Token 数量和模型标价速率
|
|
16
|
+
- **思考预览**:模型工作时,在 Pi 隐藏思考块的 `Thinking...` 位置显示实时字幕,展示推理内容的末尾片段
|
|
16
17
|
- **交互式设置**:通过 `/open-tui` 配置,并支持英文和简体中文界面
|
|
17
18
|
- **带版本保护的 Pi 兼容层**:全屏滚轮速度所依赖的运行时支持发生变化时,会回退为 Pi 默认行为
|
|
18
19
|
|
|
@@ -83,6 +84,9 @@ pi -e npm:pi-open-tui
|
|
|
83
84
|
"tokens": true,
|
|
84
85
|
"stalls": true,
|
|
85
86
|
"cost": true
|
|
87
|
+
},
|
|
88
|
+
"thinkingPeek": {
|
|
89
|
+
"lines": 1
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
```
|
|
@@ -97,6 +101,7 @@ pi -e npm:pi-open-tui
|
|
|
97
101
|
| `icons.mode` | `auto`、`nerd`、`ascii` | 控制底栏和遥测通知使用的图标 |
|
|
98
102
|
| `footerSegments` | 布尔开关 | 分别控制底栏中的各项数据 |
|
|
99
103
|
| `telemetry` | 布尔开关 | 控制遥测总开关和各项指标 |
|
|
104
|
+
| `thinkingPeek.lines` | `0`、`1`、`2` | 关闭、单行或双行思考预览 |
|
|
100
105
|
|
|
101
106
|
`sessionName` 仅在会话有名称时显示;`gitCommit` 会在 detached HEAD 状态下显示短哈希和标签;关闭 `extensionStatuses` 会隐藏整行扩展状态,其中也包括 MCP 状态。
|
|
102
107
|
|
|
@@ -114,6 +119,22 @@ TPS 的计算方式是:将本次运行中服务商报告的全部 Assistant
|
|
|
114
119
|
|
|
115
120
|
`$ / M` 表示根据 `usage.cost.total` 得到的模型标价速率,不是底栏中的会话累计费用。所有遥测字段都可以在**遥测**页单独开关。
|
|
116
121
|
|
|
122
|
+
## 思考预览
|
|
123
|
+
|
|
124
|
+
开启 Pi 的 **Hide thinking** 后,任务运行期间 pi-open-tui 会在原生隐藏思考块的 `Thinking...` 位置显示紧凑的实时字幕。`/open-tui` 中提供**关闭、单行、双行**三种模式:
|
|
125
|
+
|
|
126
|
+
- 模型推理时,思考内容的末尾片段会随 spinner 滚动(`~ think ⠋ …`);
|
|
127
|
+
- 开始输出正文时定格为对勾(`~ think ✓`);
|
|
128
|
+
- 双行模式中,上一条和最新一条思考内容使用相同的文字缩进;如果最新一行超出宽度,则两行随新 Token 持续显示它的最新末尾片段,不再保留上一条;
|
|
129
|
+
- 任务结束后恢复原生 `Thinking...` 标签。
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
~ think ⠋ 上一条思考
|
|
133
|
+
最新一条思考
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
该字幕仅在模型真正输出推理内容后出现,非推理模型不会显示。可见性由 Pi 自身的 Hide thinking 开关控制,切换后立即生效。每一行都按*显示宽度*截断(全角字符计 2 列),因此包含中文的思考文本也不会溢出终端。可在 `/open-tui` 的**常规 → 思考预览**中切换,或直接修改 `open-tui.json` 中的 `thinkingPeek.lines`。
|
|
137
|
+
|
|
117
138
|
## 本地开发
|
|
118
139
|
|
|
119
140
|
```bash
|
|
@@ -9,6 +9,7 @@ import type { IconMode } from "./icons.ts";
|
|
|
9
9
|
|
|
10
10
|
export type SettingsLanguage = "en" | "zh";
|
|
11
11
|
export type CursorStyle = "block" | "bar" | "underline";
|
|
12
|
+
export type ThinkingPeekLines = 0 | 1 | 2;
|
|
12
13
|
|
|
13
14
|
export type { IconMode } from "./icons.ts";
|
|
14
15
|
|
|
@@ -35,6 +36,10 @@ export interface TelemetryConfig {
|
|
|
35
36
|
cost: boolean;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
export interface ThinkingPeekConfig {
|
|
40
|
+
lines: ThinkingPeekLines;
|
|
41
|
+
}
|
|
42
|
+
|
|
38
43
|
export interface FullscreenConfig {
|
|
39
44
|
wheelScrollLines: number;
|
|
40
45
|
}
|
|
@@ -49,6 +54,7 @@ export interface OpenTuiConfig {
|
|
|
49
54
|
};
|
|
50
55
|
footerSegments: FooterSegments;
|
|
51
56
|
telemetry: TelemetryConfig;
|
|
57
|
+
thinkingPeek: ThinkingPeekConfig;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
@@ -82,6 +88,9 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
|
82
88
|
stalls: true,
|
|
83
89
|
cost: true,
|
|
84
90
|
},
|
|
91
|
+
thinkingPeek: {
|
|
92
|
+
lines: 1,
|
|
93
|
+
},
|
|
85
94
|
};
|
|
86
95
|
|
|
87
96
|
export function getConfigPath(): string {
|
|
@@ -89,6 +98,10 @@ export function getConfigPath(): string {
|
|
|
89
98
|
return join(agentDir, "open-tui.json");
|
|
90
99
|
}
|
|
91
100
|
|
|
101
|
+
function normalizeThinkingPeekLines(value: unknown): ThinkingPeekLines {
|
|
102
|
+
return value === 0 || value === 1 || value === 2 ? value : DEFAULT_CONFIG.thinkingPeek.lines;
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
function deepMerge<T>(base: T, override: unknown): T {
|
|
93
106
|
if (typeof base !== "object" || base === null || Array.isArray(base)) {
|
|
94
107
|
return (override as T) ?? base;
|
|
@@ -144,6 +157,11 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
|
|
|
144
157
|
config.fullscreen.wheelScrollLines,
|
|
145
158
|
DEFAULT_CONFIG.fullscreen.wheelScrollLines,
|
|
146
159
|
);
|
|
160
|
+
if (typeof config.thinkingPeek !== "object" || config.thinkingPeek === null || Array.isArray(config.thinkingPeek)) {
|
|
161
|
+
config.thinkingPeek = structuredClone(DEFAULT_CONFIG.thinkingPeek);
|
|
162
|
+
} else {
|
|
163
|
+
config.thinkingPeek.lines = normalizeThinkingPeekLines(config.thinkingPeek.lines);
|
|
164
|
+
}
|
|
147
165
|
return config;
|
|
148
166
|
} catch (err) {
|
|
149
167
|
notify?.(`open-tui config parse error: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
CustomEditor,
|
|
3
|
-
type ExtensionAPI,
|
|
1
|
+
import {
|
|
2
|
+
CustomEditor,
|
|
3
|
+
type ExtensionAPI,
|
|
4
4
|
type ExtensionContext,
|
|
5
5
|
type KeybindingsManager,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -23,7 +23,40 @@ const CURSOR_STYLE_SEQUENCES: Partial<Record<CursorStyle, string>> = {
|
|
|
23
23
|
bar: "\x1b[6 q",
|
|
24
24
|
underline: "\x1b[4 q",
|
|
25
25
|
};
|
|
26
|
-
const DEFAULT_CURSOR_STYLE_SEQUENCE = "\x1b[0 q";
|
|
26
|
+
const DEFAULT_CURSOR_STYLE_SEQUENCE = "\x1b[0 q";
|
|
27
|
+
|
|
28
|
+
interface HiddenThinkingLabelComponent {
|
|
29
|
+
children: unknown[];
|
|
30
|
+
setHiddenThinkingLabel(label: string): void;
|
|
31
|
+
setHideThinkingBlock(hide: boolean): void;
|
|
32
|
+
updateContent(message: unknown, isStreaming?: boolean): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isHiddenThinkingLabelComponent(value: object): value is HiddenThinkingLabelComponent {
|
|
36
|
+
const component = value as unknown as Record<string, unknown>;
|
|
37
|
+
return Array.isArray(component.children)
|
|
38
|
+
&& typeof component.setHiddenThinkingLabel === "function"
|
|
39
|
+
&& typeof component.setHideThinkingBlock === "function"
|
|
40
|
+
&& typeof component.updateContent === "function";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Ponytail until Pi exposes a per-message hidden-thinking label API. */
|
|
44
|
+
function findLatestHiddenThinkingLabel(tui: TUI): HiddenThinkingLabelComponent {
|
|
45
|
+
const pending: unknown[] = [tui];
|
|
46
|
+
const visited = new Set<object>();
|
|
47
|
+
let latest: HiddenThinkingLabelComponent | undefined;
|
|
48
|
+
while (pending.length > 0) {
|
|
49
|
+
const value = pending.pop();
|
|
50
|
+
if (!value || typeof value !== "object" || visited.has(value)) continue;
|
|
51
|
+
visited.add(value);
|
|
52
|
+
if (isHiddenThinkingLabelComponent(value)) latest = value;
|
|
53
|
+
const children = (value as { children?: unknown }).children;
|
|
54
|
+
if (!Array.isArray(children)) continue;
|
|
55
|
+
for (let i = children.length - 1; i >= 0; i--) pending.push(children[i]);
|
|
56
|
+
}
|
|
57
|
+
if (!latest) throw new Error("Unable to find the active hidden-thinking component");
|
|
58
|
+
return latest;
|
|
59
|
+
}
|
|
27
60
|
|
|
28
61
|
function removeSoftwareCursor(line: string, cursorMarker = ""): string {
|
|
29
62
|
return line.replace(/\x1b\[7m([\s\S]*?)\x1b\[0m/g, (_match, cursor: string) => {
|
|
@@ -159,28 +192,51 @@ export function installEditor(
|
|
|
159
192
|
) {
|
|
160
193
|
let activeTui: TUI | undefined;
|
|
161
194
|
let activeEditor: OpenTuiEditor | undefined;
|
|
162
|
-
let previousHardwareCursor: boolean | undefined;
|
|
163
|
-
let currentCursorStyle = cursorStyle;
|
|
164
|
-
let currentWheelScrollLines = wheelScrollLines;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
activeTui
|
|
168
|
-
|
|
195
|
+
let previousHardwareCursor: boolean | undefined;
|
|
196
|
+
let currentCursorStyle = cursorStyle;
|
|
197
|
+
let currentWheelScrollLines = wheelScrollLines;
|
|
198
|
+
let hiddenThinkingTarget: HiddenThinkingLabelComponent | undefined;
|
|
199
|
+
const getActiveTui = (): TUI => {
|
|
200
|
+
if (!activeTui) throw new Error("Open TUI editor is not mounted");
|
|
201
|
+
return activeTui;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
ctx.ui.setEditorComponent((tui, editorTheme, keybindings) => {
|
|
205
|
+
activeTui = tui;
|
|
206
|
+
hiddenThinkingTarget = undefined;
|
|
207
|
+
applyFullscreenWheelScrollLines(tui, currentWheelScrollLines);
|
|
169
208
|
previousHardwareCursor = tui.getShowHardwareCursor();
|
|
170
209
|
activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle);
|
|
171
210
|
return activeEditor;
|
|
172
|
-
});
|
|
173
|
-
return {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
getViewportWidth(): number {
|
|
214
|
+
const columns = getActiveTui().terminal.columns;
|
|
215
|
+
if (typeof columns !== "number" || !Number.isFinite(columns)) {
|
|
216
|
+
throw new Error("Open TUI editor terminal has an invalid width");
|
|
217
|
+
}
|
|
218
|
+
return Math.max(1, Math.floor(columns));
|
|
219
|
+
},
|
|
220
|
+
setLatestHiddenThinkingLabel(label: string): void {
|
|
221
|
+
const tui = getActiveTui();
|
|
222
|
+
hiddenThinkingTarget ??= findLatestHiddenThinkingLabel(tui);
|
|
223
|
+
hiddenThinkingTarget.setHiddenThinkingLabel(label);
|
|
224
|
+
tui.requestRender();
|
|
225
|
+
},
|
|
226
|
+
resetHiddenThinkingLabelTarget(): void {
|
|
227
|
+
hiddenThinkingTarget = undefined;
|
|
228
|
+
},
|
|
229
|
+
setCursorStyle(nextCursorStyle: CursorStyle): void {
|
|
230
|
+
currentCursorStyle = nextCursorStyle;
|
|
231
|
+
activeEditor?.setCursorStyle(nextCursorStyle, previousHardwareCursor);
|
|
177
232
|
},
|
|
178
233
|
setWheelScrollLines(nextWheelScrollLines: number): void {
|
|
179
234
|
currentWheelScrollLines = nextWheelScrollLines;
|
|
180
235
|
if (activeTui) applyFullscreenWheelScrollLines(activeTui, currentWheelScrollLines);
|
|
181
236
|
},
|
|
182
|
-
cleanup(): void {
|
|
183
|
-
|
|
237
|
+
cleanup(): void {
|
|
238
|
+
hiddenThinkingTarget = undefined;
|
|
239
|
+
ctx.ui.setEditorComponent(undefined);
|
|
184
240
|
if (activeTui) {
|
|
185
241
|
if (currentCursorStyle !== "block") activeTui.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
|
|
186
242
|
if (previousHardwareCursor !== undefined) activeTui.setShowHardwareCursor(previousHardwareCursor);
|
|
@@ -14,31 +14,25 @@ import {
|
|
|
14
14
|
invalidateUsageCache,
|
|
15
15
|
type FooterState,
|
|
16
16
|
} from "./state.ts";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
17
|
+
import { resolveGlyphs } from "./icons.ts";
|
|
18
|
+
import {
|
|
19
|
+
buildPeekLabel,
|
|
20
|
+
collectPeekParts,
|
|
21
|
+
createPeekState,
|
|
22
|
+
reducePeek,
|
|
23
|
+
type PeekState,
|
|
24
|
+
} from "./peek.ts";
|
|
25
|
+
|
|
26
|
+
type PendingUiChange = "install" | "uninstall";
|
|
27
|
+
const HIDDEN_THINKING_HORIZONTAL_PADDING = 2;
|
|
28
|
+
// Pi's fullscreen transcript can reserve one more column for its scrollbar.
|
|
29
|
+
const HIDDEN_THINKING_SCROLLBAR_RESERVE = 1;
|
|
30
30
|
|
|
31
31
|
export function getPendingUiChange(enabled: boolean, active: boolean): PendingUiChange | undefined {
|
|
32
32
|
if (enabled === active) return undefined;
|
|
33
33
|
return enabled ? "install" : "uninstall";
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function clearVisibleScreen(): void {
|
|
37
|
-
if (process.stdout.isTTY) {
|
|
38
|
-
process.stdout.write("\x1b[2J\x1b[H");
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
36
|
function isTuiContext(ctx: ExtensionContext): boolean {
|
|
43
37
|
try {
|
|
44
38
|
const mode = (ctx as ExtensionContext & { mode?: string }).mode;
|
|
@@ -63,8 +57,72 @@ export default function (pi: ExtensionAPI) {
|
|
|
63
57
|
let editor: ReturnType<typeof installEditor> | undefined;
|
|
64
58
|
let pendingUiChange: PendingUiChange | undefined;
|
|
65
59
|
|
|
60
|
+
// thinking-peek state (rendered by Pi inside its hidden-thinking block)
|
|
61
|
+
const peek: PeekState = createPeekState();
|
|
62
|
+
let peekFrame = 0;
|
|
63
|
+
let peekSettleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
let peekTaskEpoch = 0; // bumped at every agent task boundary
|
|
65
|
+
let peekLabelActive = false;
|
|
66
|
+
|
|
66
67
|
const getThinkingLevel = () => (sessionLifecycle.isCurrent() ? pi.getThinkingLevel() : "off");
|
|
67
68
|
|
|
69
|
+
/** True while the agent is running (false during session-restore replay). */
|
|
70
|
+
const isAgentIdle = (ctx: ExtensionContext): boolean => {
|
|
71
|
+
try {
|
|
72
|
+
return (ctx as ExtensionContext & { isIdle?: () => boolean }).isIdle?.() === true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Pi decides whether this label is visible; our toggle only controls updates. */
|
|
79
|
+
const isPeekEnabled = () => sessionLifecycle.isCurrent() && config.enabled && config.thinkingPeek.lines > 0 && active;
|
|
80
|
+
|
|
81
|
+
/** Keep each native hidden-thinking label row within Pi's narrowest transcript width. */
|
|
82
|
+
const hiddenThinkingLabelWidth = (): number => {
|
|
83
|
+
if (!editor) throw new Error("Open TUI editor is not installed");
|
|
84
|
+
return Math.max(
|
|
85
|
+
1,
|
|
86
|
+
editor.getViewportWidth() - HIDDEN_THINKING_HORIZONTAL_PADDING - HIDDEN_THINKING_SCROLLBAR_RESERVE,
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const setPeekLabel = (ctx?: ExtensionContext): void => {
|
|
91
|
+
if (!isPeekEnabled() || peek.phase === "idle") return;
|
|
92
|
+
const target = ctx ?? lastCtx;
|
|
93
|
+
if (!target || !isTuiContext(target)) return;
|
|
94
|
+
if (!editor) throw new Error("Open TUI editor is not installed");
|
|
95
|
+
editor.setLatestHiddenThinkingLabel(
|
|
96
|
+
buildPeekLabel(
|
|
97
|
+
peek,
|
|
98
|
+
peekFrame,
|
|
99
|
+
resolveGlyphs(config.icons.mode),
|
|
100
|
+
hiddenThinkingLabelWidth(),
|
|
101
|
+
config.thinkingPeek.lines,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
peekLabelActive = true;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const clearPeekLabel = (ctx?: ExtensionContext): void => {
|
|
108
|
+
if (!peekLabelActive) return;
|
|
109
|
+
const target = ctx ?? lastCtx;
|
|
110
|
+
if (!target || !isTuiContext(target)) return;
|
|
111
|
+
// The global reset is safe here: every completed message returns to Pi's native label.
|
|
112
|
+
target.ui.setHiddenThinkingLabel();
|
|
113
|
+
peekLabelActive = false;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Reset peek state to idle and cancel any scheduled cleanup. */
|
|
117
|
+
const resetPeek = () => {
|
|
118
|
+
peek.phase = "idle";
|
|
119
|
+
peek.tail = "";
|
|
120
|
+
editor?.resetHiddenThinkingLabelTarget();
|
|
121
|
+
if (peekSettleTimer) {
|
|
122
|
+
clearTimeout(peekSettleTimer);
|
|
123
|
+
peekSettleTimer = undefined;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
68
126
|
const applyUi = (ctx: ExtensionContext) => {
|
|
69
127
|
if (!isTuiContext(ctx)) return;
|
|
70
128
|
if (!config.enabled) {
|
|
@@ -102,6 +160,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
102
160
|
cleanupFooter = undefined;
|
|
103
161
|
editor = undefined;
|
|
104
162
|
requestFooterRender = undefined;
|
|
163
|
+
resetPeek();
|
|
164
|
+
clearPeekLabel(ctx);
|
|
105
165
|
active = false;
|
|
106
166
|
}
|
|
107
167
|
};
|
|
@@ -173,10 +233,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
173
233
|
|
|
174
234
|
ensureConfigExists();
|
|
175
235
|
config = loadConfig((msg, level) => ctx.ui.notify(msg, level));
|
|
176
|
-
|
|
177
|
-
if (isInteractiveLaunch() && config.enabled) {
|
|
178
|
-
clearVisibleScreen();
|
|
179
|
-
}
|
|
236
|
+
clearPeekLabel(ctx);
|
|
180
237
|
|
|
181
238
|
applyUi(ctx);
|
|
182
239
|
|
|
@@ -195,6 +252,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
195
252
|
pi.on("agent_start", (event, _ctx) => {
|
|
196
253
|
turnTelemetry.handle(event);
|
|
197
254
|
if (!sessionLifecycle.isCurrent()) return;
|
|
255
|
+
// agent_start also covers custom-message and continuation-triggered tasks
|
|
256
|
+
// that do not emit a user message_start event.
|
|
257
|
+
peekTaskEpoch++;
|
|
198
258
|
state.workingSince = Date.now();
|
|
199
259
|
state.lastDoneIn = undefined;
|
|
200
260
|
startWorkingTimer();
|
|
@@ -214,12 +274,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
274
|
turnTelemetry.handle(event);
|
|
215
275
|
});
|
|
216
276
|
|
|
217
|
-
pi.on("message_start", (event) => {
|
|
277
|
+
pi.on("message_start", (event, ctx) => {
|
|
218
278
|
turnTelemetry.handle(event);
|
|
279
|
+
if (!sessionLifecycle.isCurrent() || isAgentIdle(ctx)) return;
|
|
280
|
+
const role = event.message?.role;
|
|
281
|
+
if (role === "user") {
|
|
282
|
+
// Restore Pi's native label until real thinking arrives. The task epoch is
|
|
283
|
+
// advanced by agent_start so custom/no-user tasks are covered too.
|
|
284
|
+
resetPeek();
|
|
285
|
+
clearPeekLabel(ctx);
|
|
286
|
+
} else if (role === "assistant") {
|
|
287
|
+
resetPeek();
|
|
288
|
+
clearPeekLabel(ctx);
|
|
289
|
+
}
|
|
219
290
|
});
|
|
220
291
|
|
|
221
|
-
pi.on("message_update", (event) => {
|
|
292
|
+
pi.on("message_update", (event, ctx) => {
|
|
222
293
|
turnTelemetry.handle(event);
|
|
294
|
+
if (!sessionLifecycle.isCurrent() || isAgentIdle(ctx)) return;
|
|
295
|
+
if (!isPeekEnabled()) return;
|
|
296
|
+
const message = event.message;
|
|
297
|
+
if (!message || message.role !== "assistant") return;
|
|
298
|
+
if (peek.phase === "done") return;
|
|
299
|
+
const parts = collectPeekParts(message.content);
|
|
300
|
+
const next = reducePeek(peek, parts);
|
|
301
|
+
const changed = next.phase !== peek.phase || next.tail !== peek.tail;
|
|
302
|
+
peek.phase = next.phase;
|
|
303
|
+
peek.tail = next.tail;
|
|
304
|
+
if (!changed) return;
|
|
305
|
+
if (peek.phase === "thinking") {
|
|
306
|
+
peekFrame++;
|
|
307
|
+
setPeekLabel(ctx);
|
|
308
|
+
} else if (peek.phase === "done") {
|
|
309
|
+
setPeekLabel(ctx);
|
|
310
|
+
}
|
|
223
311
|
});
|
|
224
312
|
|
|
225
313
|
pi.on("tool_execution_start", (event) => {
|
|
@@ -236,6 +324,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
236
324
|
const message = formatTurnTelemetry(telemetry, ctx.ui.theme, config.telemetry, config.icons.mode);
|
|
237
325
|
if (message) ctx.ui.notify(message, "info");
|
|
238
326
|
}
|
|
327
|
+
// Only clear the peek label when this task is still the current one.
|
|
328
|
+
const settleEpoch = peekTaskEpoch;
|
|
329
|
+
if (peekSettleTimer) clearTimeout(peekSettleTimer);
|
|
330
|
+
peekSettleTimer = setTimeout(() => {
|
|
331
|
+
peekSettleTimer = undefined;
|
|
332
|
+
if (!sessionLifecycle.isCurrent() || peekTaskEpoch !== settleEpoch) return;
|
|
333
|
+
resetPeek();
|
|
334
|
+
clearPeekLabel(ctx);
|
|
335
|
+
}, 300);
|
|
239
336
|
});
|
|
240
337
|
|
|
241
338
|
pi.on("model_select", (_event, ctx) => {
|
|
@@ -249,6 +346,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
249
346
|
pi.on("message_end", (event, ctx) => {
|
|
250
347
|
turnTelemetry.handle(event);
|
|
251
348
|
if (!sessionLifecycle.isCurrent()) return;
|
|
349
|
+
if (event.message?.role === "assistant" && peek.phase === "thinking") {
|
|
350
|
+
// Sub-message finished (answer text or a tool call): freeze the peek line.
|
|
351
|
+
peek.phase = "done";
|
|
352
|
+
setPeekLabel(ctx);
|
|
353
|
+
}
|
|
252
354
|
invalidateUsageCache();
|
|
253
355
|
refreshInteractiveState(ctx);
|
|
254
356
|
});
|
|
@@ -274,8 +376,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
274
376
|
onConfigChanged: (newConfig) => {
|
|
275
377
|
const cursorStyleChanged = config.cursorStyle !== newConfig.cursorStyle;
|
|
276
378
|
const wheelScrollLinesChanged = config.fullscreen.wheelScrollLines !== newConfig.fullscreen.wheelScrollLines;
|
|
379
|
+
const thinkingPeekLinesChanged = config.thinkingPeek.lines !== newConfig.thinkingPeek.lines;
|
|
277
380
|
saveConfig(newConfig);
|
|
278
381
|
config = newConfig;
|
|
382
|
+
if (newConfig.thinkingPeek.lines === 0 || !newConfig.enabled) {
|
|
383
|
+
resetPeek();
|
|
384
|
+
clearPeekLabel(lastCtx);
|
|
385
|
+
} else if (thinkingPeekLinesChanged && peekLabelActive) {
|
|
386
|
+
setPeekLabel(lastCtx);
|
|
387
|
+
}
|
|
279
388
|
if (cursorStyleChanged && active && editor) {
|
|
280
389
|
editor.setCursorStyle(newConfig.cursorStyle);
|
|
281
390
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { IconGlyphs } from "./icons.ts";
|
|
3
|
+
import { sanitizeStatus } from "./utils.ts";
|
|
4
|
+
|
|
5
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
6
|
+
const MAX_PEEK_LINES = 2;
|
|
7
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
8
|
+
|
|
9
|
+
export type PeekPhase = "idle" | "thinking" | "done";
|
|
10
|
+
|
|
11
|
+
export interface PeekState {
|
|
12
|
+
phase: PeekPhase;
|
|
13
|
+
tail: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PeekMessageParts {
|
|
17
|
+
thinking: string;
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function stringValue(value: unknown): string | undefined {
|
|
22
|
+
return typeof value === "string" ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const normalizePeekLineCount = (lineCount: number): number => Math.max(1, Math.min(MAX_PEEK_LINES, Math.floor(lineCount)));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pure phase machine for one assistant-message update.
|
|
29
|
+
*
|
|
30
|
+
* Thinking text is cumulative: once the answer starts, later updates still
|
|
31
|
+
* carry the full thinking block alongside new text. To keep the spinner honest:
|
|
32
|
+
* - idle + thinking -> thinking (spinner starts)
|
|
33
|
+
* - idle + thinking + text -> done (already answered, never spin)
|
|
34
|
+
* - thinking + text -> done (answer started: stop spinner)
|
|
35
|
+
* - done + thinking -> done (never restart the spinner)
|
|
36
|
+
* - any + text only -> idle stays idle (plain answer, nothing to peek)
|
|
37
|
+
*/
|
|
38
|
+
export function reducePeek(prev: PeekState, parts: PeekMessageParts): PeekState {
|
|
39
|
+
const tail = parts.thinking ? peekTail(parts.thinking, MAX_PEEK_LINES) : prev.tail;
|
|
40
|
+
let phase = prev.phase;
|
|
41
|
+
if (parts.thinking && phase === "idle") phase = "thinking";
|
|
42
|
+
if (parts.text && phase === "thinking") phase = "done";
|
|
43
|
+
return { phase, tail };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Fresh peek state (no task running yet). */
|
|
47
|
+
export function createPeekState(): PeekState {
|
|
48
|
+
return { phase: "idle", tail: "" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Collect the running thinking text and answer text from an assistant
|
|
53
|
+
* message's content parts. Thinking arrives token-by-token, so re-calling
|
|
54
|
+
* this on every `message_update` yields the cumulative text.
|
|
55
|
+
*/
|
|
56
|
+
export function collectPeekParts(content: unknown): PeekMessageParts {
|
|
57
|
+
let thinking = "";
|
|
58
|
+
let text = "";
|
|
59
|
+
if (!Array.isArray(content)) return { thinking, text };
|
|
60
|
+
for (const rawPart of content) {
|
|
61
|
+
if (!rawPart || typeof rawPart !== "object") continue;
|
|
62
|
+
const part = rawPart as { type?: unknown; thinking?: unknown; text?: unknown };
|
|
63
|
+
if (part.type === "thinking") {
|
|
64
|
+
thinking += stringValue(part.thinking) ?? stringValue(part.text) ?? "";
|
|
65
|
+
} else if (part.type === "text") {
|
|
66
|
+
text += stringValue(part.text) ?? "";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { thinking, text };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Extract the last non-empty logical lines of the thinking text, whitespace-
|
|
74
|
+
* normalized. NOT width-bounded: clipping to the terminal happens at render
|
|
75
|
+
* time in buildPeekLabel/clipTail, so wide terminals get to see more of it.
|
|
76
|
+
*/
|
|
77
|
+
export function peekTail(fullThinking: string, lineCount = 1): string {
|
|
78
|
+
if (!fullThinking) return "";
|
|
79
|
+
const count = normalizePeekLineCount(lineCount);
|
|
80
|
+
const lines: string[] = [];
|
|
81
|
+
let end = fullThinking.length;
|
|
82
|
+
while (end > 0 && lines.length < count) {
|
|
83
|
+
const start = fullThinking.lastIndexOf("\n", end - 1) + 1;
|
|
84
|
+
const line = fullThinking.slice(start, end).replace(/\s+/g, " ").trim();
|
|
85
|
+
if (line) lines.push(line);
|
|
86
|
+
end = start - 1;
|
|
87
|
+
}
|
|
88
|
+
return lines.reverse().join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Clip a tail string to `budget` visible columns, keeping the END (it is a
|
|
93
|
+
* tail view) and prefixing "…" when clipped. Walk backwards over complete
|
|
94
|
+
* graphemes so measurement is limited to the visible suffix.
|
|
95
|
+
*/
|
|
96
|
+
export function clipTail(text: string, budget: number): string {
|
|
97
|
+
if (!text || budget <= 0) return "";
|
|
98
|
+
const limit = budget - 1; // reserve one column for the ellipsis
|
|
99
|
+
let w = 0;
|
|
100
|
+
let end = text.length;
|
|
101
|
+
let clippedStart = end;
|
|
102
|
+
const segments = graphemeSegmenter.segment(text);
|
|
103
|
+
while (end > 0) {
|
|
104
|
+
const part = segments.containing(end - 1)!;
|
|
105
|
+
w += visibleWidth(part.segment);
|
|
106
|
+
if (w > budget) return "…" + text.slice(clippedStart);
|
|
107
|
+
end = part.index;
|
|
108
|
+
if (w <= limit) clippedStart = end;
|
|
109
|
+
}
|
|
110
|
+
return text;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build the label Pi renders in its hidden-thinking block. `width` is a
|
|
115
|
+
* conservative budget that keeps each native Text row within the terminal.
|
|
116
|
+
* In two-line mode, an overflowing latest logical line uses both rows for
|
|
117
|
+
* its continuation instead of retaining the previous logical line.
|
|
118
|
+
*/
|
|
119
|
+
export function buildPeekLabel(
|
|
120
|
+
state: PeekState,
|
|
121
|
+
frame: number,
|
|
122
|
+
glyphs: IconGlyphs,
|
|
123
|
+
width: number,
|
|
124
|
+
lineCount = 1,
|
|
125
|
+
): string {
|
|
126
|
+
const w = Math.max(1, Math.floor(width));
|
|
127
|
+
const prefix = `${glyphs.thinking} think`;
|
|
128
|
+
const count = normalizePeekLineCount(lineCount);
|
|
129
|
+
const fit = (line: string): string => truncateToWidth(line, w, "…");
|
|
130
|
+
switch (state.phase) {
|
|
131
|
+
case "thinking": {
|
|
132
|
+
const spin = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? "";
|
|
133
|
+
// Thinking text is model output (untrusted): strip terminal control
|
|
134
|
+
// sequences before placing it in Pi's native label.
|
|
135
|
+
const marker = `${prefix} ${spin}`;
|
|
136
|
+
const markerWidth = visibleWidth(`${marker} `);
|
|
137
|
+
const contentWidth = Math.max(0, w - markerWidth);
|
|
138
|
+
const thoughtIndent = " ".repeat(markerWidth);
|
|
139
|
+
const safeLines = state.tail
|
|
140
|
+
.split("\n")
|
|
141
|
+
.map((tail) => sanitizeStatus(tail))
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.slice(-count);
|
|
144
|
+
const latest = safeLines.at(-1) ?? "";
|
|
145
|
+
const lineWithMarker = (tail: string): string => fit(tail ? `${marker} ${tail}` : marker);
|
|
146
|
+
if (count >= 2) {
|
|
147
|
+
const window = clipTail(latest, contentWidth * count);
|
|
148
|
+
const latestWrapped = wrapTextWithAnsi(window, Math.max(1, contentWidth));
|
|
149
|
+
if (latestWrapped.length > 1) {
|
|
150
|
+
// Word wrapping may add short rows; always retain the newest content.
|
|
151
|
+
const first = latestWrapped.at(-2) ?? "";
|
|
152
|
+
const second = latestWrapped.at(-1) ?? "";
|
|
153
|
+
return `${lineWithMarker(first)}\n${fit(`${thoughtIndent}${second}`)}`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const firstTail = clipTail(count >= 2 ? safeLines[0] ?? "" : latest, contentWidth);
|
|
157
|
+
if (count < 2 || safeLines.length < 2) return lineWithMarker(firstTail);
|
|
158
|
+
// Align the latest line with the first line's thinking text, not with
|
|
159
|
+
// the status marker.
|
|
160
|
+
const secondTail = clipTail(latest, contentWidth);
|
|
161
|
+
return `${lineWithMarker(firstTail)}\n${fit(secondTail ? `${thoughtIndent}${secondTail}` : thoughtIndent)}`;
|
|
162
|
+
}
|
|
163
|
+
case "done":
|
|
164
|
+
return fit(`${prefix} ${glyphs.done}`);
|
|
165
|
+
default:
|
|
166
|
+
return fit(`${prefix} ·`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -5,11 +5,12 @@ import {
|
|
|
5
5
|
Key,
|
|
6
6
|
matchesKey,
|
|
7
7
|
SelectList,
|
|
8
|
+
type Component,
|
|
8
9
|
type SelectItem,
|
|
9
10
|
type TUI,
|
|
10
11
|
Text,
|
|
11
12
|
} from "@earendil-works/pi-tui";
|
|
12
|
-
import type { CursorStyle, IconMode, OpenTuiConfig, SettingsLanguage } from "./config.ts";
|
|
13
|
+
import type { CursorStyle, IconMode, OpenTuiConfig, SettingsLanguage, ThinkingPeekLines } from "./config.ts";
|
|
13
14
|
import {
|
|
14
15
|
DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
|
|
15
16
|
normalizeFullscreenWheelScrollLines,
|
|
@@ -32,6 +33,7 @@ const COPY = {
|
|
|
32
33
|
hint: "Tab/Shift+Tab/←/→: tabs · ↑/↓: move · Enter/Space: change · Enter on wheel speed: type 1-10 · Esc/q: close",
|
|
33
34
|
labels: {
|
|
34
35
|
enabled: "Enabled",
|
|
36
|
+
thinkingPeek: "Thinking peek",
|
|
35
37
|
language: "Language",
|
|
36
38
|
wheelScrollLines: "Mouse wheel speed",
|
|
37
39
|
cursorStyle: "Cursor style",
|
|
@@ -54,6 +56,7 @@ const COPY = {
|
|
|
54
56
|
values: {
|
|
55
57
|
on: "On",
|
|
56
58
|
off: "Off",
|
|
59
|
+
thinkingPeek: { off: "Off", one: "1 line", two: "2 lines" },
|
|
57
60
|
languages: { en: "English", zh: "简体中文" },
|
|
58
61
|
wheelLines: (count: number) => `${count} ${count === 1 ? "line" : "lines"} / notch`,
|
|
59
62
|
wheelPrompt: (count: number) => `Wheel scroll lines per notch, 1-10 (current: ${count}). Enter: apply · Esc: cancel`,
|
|
@@ -67,6 +70,7 @@ const COPY = {
|
|
|
67
70
|
hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · 滚轮速度项 Enter 输入 1-10 · Esc/q:关闭",
|
|
68
71
|
labels: {
|
|
69
72
|
enabled: "启用",
|
|
73
|
+
thinkingPeek: "思考预览",
|
|
70
74
|
language: "语言",
|
|
71
75
|
wheelScrollLines: "鼠标滚轮速度",
|
|
72
76
|
cursorStyle: "光标样式",
|
|
@@ -89,6 +93,7 @@ const COPY = {
|
|
|
89
93
|
values: {
|
|
90
94
|
on: "开启",
|
|
91
95
|
off: "关闭",
|
|
96
|
+
thinkingPeek: { off: "关闭", one: "单行", two: "双行" },
|
|
92
97
|
languages: { en: "English", zh: "简体中文" },
|
|
93
98
|
wheelLines: (count: number) => `每格 ${count} 行`,
|
|
94
99
|
wheelPrompt: (count: number) => `滚轮每格滚动行数(当前 ${count},范围 1-10),输入后 Enter 应用 · Esc 取消`,
|
|
@@ -100,6 +105,11 @@ const COPY = {
|
|
|
100
105
|
|
|
101
106
|
type SettingsCopy = (typeof COPY)[SettingsLanguage];
|
|
102
107
|
|
|
108
|
+
function formatThinkingPeekLines(lines: ThinkingPeekLines, copy: SettingsCopy): string {
|
|
109
|
+
const values = [copy.values.thinkingPeek.off, copy.values.thinkingPeek.one, copy.values.thinkingPeek.two];
|
|
110
|
+
return values[lines] ?? values[0];
|
|
111
|
+
}
|
|
112
|
+
|
|
103
113
|
function toggleSetting(config: OpenTuiConfig, key: keyof OpenTuiConfig["footerSegments"]): OpenTuiConfig {
|
|
104
114
|
return {
|
|
105
115
|
...config,
|
|
@@ -152,15 +162,25 @@ function toggleTelemetry(config: OpenTuiConfig, key: keyof OpenTuiConfig["teleme
|
|
|
152
162
|
};
|
|
153
163
|
}
|
|
154
164
|
|
|
165
|
+
function cycleThinkingPeek(config: OpenTuiConfig): OpenTuiConfig {
|
|
166
|
+
const next = ([1, 2, 0] as const)[config.thinkingPeek.lines] ?? 0;
|
|
167
|
+
return {
|
|
168
|
+
...config,
|
|
169
|
+
thinkingPeek: { lines: next },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
155
173
|
function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingItem[] {
|
|
174
|
+
const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
|
|
156
175
|
return [
|
|
157
|
-
{ id: "enabled", label: copy.labels.enabled, currentValue: config.enabled
|
|
176
|
+
{ id: "enabled", label: copy.labels.enabled, currentValue: flag(config.enabled) },
|
|
158
177
|
{ id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
|
|
159
178
|
{
|
|
160
179
|
id: "wheelScrollLines",
|
|
161
180
|
label: copy.labels.wheelScrollLines,
|
|
162
181
|
currentValue: copy.values.wheelLines(config.fullscreen.wheelScrollLines),
|
|
163
182
|
},
|
|
183
|
+
{ id: "thinkingPeek", label: copy.labels.thinkingPeek, currentValue: formatThinkingPeekLines(config.thinkingPeek.lines, copy) },
|
|
164
184
|
];
|
|
165
185
|
}
|
|
166
186
|
|
|
@@ -220,6 +240,7 @@ function handleSettingChange(
|
|
|
220
240
|
if (tab === "features") {
|
|
221
241
|
if (itemId === "enabled") return toggleEnabled(config);
|
|
222
242
|
if (itemId === "settingsLanguage") return toggleLanguage(config);
|
|
243
|
+
if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
|
|
223
244
|
}
|
|
224
245
|
if (tab === "icons") {
|
|
225
246
|
if (itemId === "mode") return cycleIconMode(config);
|
|
@@ -240,6 +261,23 @@ interface SettingsUiHandle {
|
|
|
240
261
|
handleInput: (data: string) => void;
|
|
241
262
|
}
|
|
242
263
|
|
|
264
|
+
function insertComponentAfter(list: Component, child: Component, index: () => number): Component {
|
|
265
|
+
// SelectList renders one line per item; splice the editor into that output.
|
|
266
|
+
return {
|
|
267
|
+
render(width: number): string[] {
|
|
268
|
+
const lines = list.render(width);
|
|
269
|
+
const insertAt = index();
|
|
270
|
+
if (insertAt < 0 || insertAt >= lines.length) return lines;
|
|
271
|
+
const childLines = child.render(width);
|
|
272
|
+
return [...lines.slice(0, insertAt + 1), ...childLines, ...lines.slice(insertAt + 1)];
|
|
273
|
+
},
|
|
274
|
+
invalidate(): void {
|
|
275
|
+
list.invalidate();
|
|
276
|
+
child.invalidate();
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
243
281
|
class SettingsUi implements SettingsUiHandle {
|
|
244
282
|
private tab: Tab = "features";
|
|
245
283
|
private config: OpenTuiConfig;
|
|
@@ -357,7 +395,6 @@ class SettingsUi implements SettingsUiHandle {
|
|
|
357
395
|
this.selectList.onCancel = () => {
|
|
358
396
|
this.onClose();
|
|
359
397
|
};
|
|
360
|
-
this.container.addChild(this.selectList);
|
|
361
398
|
if (editingWheel) {
|
|
362
399
|
this.wheelInput!.focused = true;
|
|
363
400
|
const wheelInputGroup = new Box(4, 0);
|
|
@@ -367,7 +404,10 @@ class SettingsUi implements SettingsUiHandle {
|
|
|
367
404
|
0,
|
|
368
405
|
));
|
|
369
406
|
wheelInputGroup.addChild(this.wheelInput!);
|
|
370
|
-
this.
|
|
407
|
+
const selectedIndex = () => items.findIndex((item) => item.value === this.selectList.getSelectedItem()?.value);
|
|
408
|
+
this.container.addChild(insertComponentAfter(this.selectList, wheelInputGroup, selectedIndex));
|
|
409
|
+
} else {
|
|
410
|
+
this.container.addChild(this.selectList);
|
|
371
411
|
}
|
|
372
412
|
|
|
373
413
|
this.cachedWidth = undefined;
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
2
|
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
stripTerminalSequences as stripTerminalSequencesFromTui,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
5
9
|
|
|
6
10
|
export { truncateToWidth, visibleWidth };
|
|
7
11
|
|
|
12
|
+
// pi-tui handles the common ANSI/OSC/APC forms. Keep a generic CSI pass for
|
|
13
|
+
// valid sequences whose final byte is outside its intentionally narrow set.
|
|
14
|
+
const CSI_SEQUENCE = /(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g;
|
|
15
|
+
|
|
8
16
|
export function stripAnsi(text: string): string {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
.replace(/\x1b_[^\x07]*\x07/g, "");
|
|
17
|
+
// Remove CSI first so pi-tui's parser cannot mistake an unsupported CSI
|
|
18
|
+
// final byte for part of a later sequence and swallow intervening text.
|
|
19
|
+
return stripTerminalSequencesFromTui(text.replace(CSI_SEQUENCE, ""));
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export function formatCwd(cwd: string): string {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-open-tui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "A polished TUI for Pi coding agent: animated logo header, Starship-style footer, rounded editor with model metadata, and prompt-box user messages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node --test tests/settings-command.test.ts tests/fullscreen-scroll.test.ts tests/footer.test.ts tests/editor.test.ts tests/telemetry.test.ts",
|
|
28
|
+
"test": "node --test tests/settings-command.test.ts tests/fullscreen-scroll.test.ts tests/footer.test.ts tests/editor.test.ts tests/telemetry.test.ts tests/peek.test.ts",
|
|
29
29
|
"typecheck": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
31
|
"pi": {
|