pi-open-tui 0.3.0 → 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 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
- ctx.ui.setEditorComponent((tui, editorTheme, keybindings) => {
167
- activeTui = tui;
168
- applyFullscreenWheelScrollLines(tui, currentWheelScrollLines);
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
- setCursorStyle(nextCursorStyle: CursorStyle): void {
175
- currentCursorStyle = nextCursorStyle;
176
- activeEditor?.setCursorStyle(nextCursorStyle, previousHardwareCursor);
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
- ctx.ui.setEditorComponent(undefined);
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
- function isInteractiveLaunch(): boolean {
19
- if (!process.stdout.isTTY) return false;
20
- const args = process.argv.slice(2);
21
- const nonInteractiveFlags = ["-p", "--print", "--help", "-h", "--version", "-v", "--list-models", "--export"];
22
- for (const arg of args) {
23
- if (nonInteractiveFlags.includes(arg)) return false;
24
- if (arg.startsWith("--mode")) return false;
25
- }
26
- return true;
27
- }
28
-
29
- type PendingUiChange = "install" | "uninstall";
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 ? copy.values.on : copy.values.off },
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.container.addChild(wheelInputGroup);
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,5 +1,5 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
1
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { AssistantMessage } from "@earendil-works/pi-ai";
3
3
  import type { GitStatus } from "./git.ts";
4
4
  import { emptyGitStatus } from "./git.ts";
5
5
  import type { RuntimeInfo } from "./runtime.ts";
@@ -39,24 +39,32 @@ export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
39
39
  latestCacheHitRate: undefined,
40
40
  };
41
41
  for (const entry of ctx.sessionManager.getEntries()) {
42
- if (entry.type === "message" && entry.message?.role === "assistant") {
43
- const m = entry.message as AssistantMessage;
44
- const u = m.usage;
45
- if (!u) continue;
46
- const input = finiteOrZero(u.input);
47
- const cacheRead = finiteOrZero(u.cacheRead);
48
- const cacheWrite = finiteOrZero(u.cacheWrite);
49
- // input matches /session's "uncached" total: cacheWrite is billed near full
50
- // price (fresh content), only cacheRead is discounted repeat content.
51
- totals.input += input + cacheWrite;
52
- totals.output += finiteOrZero(u.output);
53
- totals.cacheRead += cacheRead;
54
- totals.cacheWrite += cacheWrite;
55
- totals.cost += finiteOrZero(u.cost?.total);
42
+ let u: Usage | undefined;
43
+ let updateCacheHitRate = false;
44
+ if (entry.type === "message" && entry.message.role === "assistant") {
45
+ u = entry.message.usage;
46
+ updateCacheHitRate = true;
47
+ } else if (entry.type === "message" && entry.message.role === "toolResult") {
48
+ u = entry.message.usage;
49
+ } else if (entry.type === "branch_summary" || entry.type === "compaction") {
50
+ u = entry.usage;
51
+ }
52
+ if (!u) continue;
53
+ const input = finiteOrZero(u.input);
54
+ const cacheRead = finiteOrZero(u.cacheRead);
55
+ const cacheWrite = finiteOrZero(u.cacheWrite);
56
+ // input matches /session's "uncached" total: cacheWrite is billed near full
57
+ // price (fresh content), only cacheRead is discounted repeat content.
58
+ totals.input += input + cacheWrite;
59
+ totals.output += finiteOrZero(u.output);
60
+ totals.cacheRead += cacheRead;
61
+ totals.cacheWrite += cacheWrite;
62
+ totals.cost += finiteOrZero(u.cost?.total);
63
+ if (updateCacheHitRate) {
56
64
  const promptTokens = input + cacheRead + cacheWrite;
57
- if (promptTokens > 0) {
58
- totals.latestCacheHitRate = (cacheRead / promptTokens) * 100;
59
- }
65
+ totals.latestCacheHitRate = promptTokens > 0
66
+ ? (cacheRead / promptTokens) * 100
67
+ : undefined;
60
68
  }
61
69
  }
62
70
  usageCache = { key, totals };
@@ -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 { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
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
- return text
10
- .replace(/\x1b\[[0-9;]*m/g, "")
11
- .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
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.0",
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": {
@@ -35,9 +35,9 @@
35
35
  "image": "https://raw.githubusercontent.com/OldSuns/pi-open-tui/main/assets/preview_dashboard_1.png"
36
36
  },
37
37
  "peerDependencies": {
38
- "@earendil-works/pi-coding-agent": ">=0.80",
39
- "@earendil-works/pi-ai": ">=0.80",
40
- "@earendil-works/pi-tui": ">=0.80"
38
+ "@earendil-works/pi-coding-agent": ">=0.81",
39
+ "@earendil-works/pi-ai": ">=0.81",
40
+ "@earendil-works/pi-tui": ">=0.81"
41
41
  },
42
42
  "peerDependenciesMeta": {
43
43
  "@earendil-works/pi-coding-agent": {