pi-open-tui 0.3.11 → 0.3.13

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
@@ -49,6 +49,8 @@ The default `auto` mode checks the terminal environment, not the installed font
49
49
 
50
50
  If the font is installed but `auto` still selects ASCII, choose `nerd` explicitly. In VS Code, Windows Terminal, and similar apps, configure the font in the terminal profile rather than only installing it in the operating system. If your SSH client terminal does ship a Nerd Font and you want the full icon set over SSH, set the mode to `nerd` explicitly.
51
51
 
52
+ If wide Nerd Font icons visually consume the gap before their labels (for example in Kitty), enable `footerSegments.nerdIconSpacing` (**Footer → Extra space after Nerd icons** in `/open-tui`). The footer then reserves an extra blank cell after Nerd Font icons, including runtime icons, and includes it in regular and inline footer width calculations. Unicode/ASCII icons and compact Git status flags are unchanged. In Kitty, `narrow_symbols U+E000-U+F8FF 1` in `kitty.conf` is an alternative.
53
+
52
54
  ## Configuration
53
55
 
54
56
  Run `/open-tui` to open the settings dialog. It provides **General**, **Appearance**, **Footer**, and **Telemetry** tabs. Settings are stored in `~/.pi/agent/open-tui.json`:
@@ -73,8 +75,10 @@ Run `/open-tui` to open the settings dialog. It provides **General**, **Appearan
73
75
  "context": true,
74
76
  "tokens": true,
75
77
  "cost": true,
78
+ "liveTps": true,
76
79
  "extensionStatuses": true,
77
- "capitalizeProviderName": true
80
+ "capitalizeProviderName": true,
81
+ "nerdIconSpacing": false
78
82
  },
79
83
  "telemetry": {
80
84
  "enabled": true,
@@ -101,10 +105,11 @@ Key options:
101
105
  | `icons.mode` | `auto`, `nerd`, `unicode`, `ascii` | Controls footer and telemetry icons |
102
106
  | `footerSegments` | Boolean flags | Shows or hides individual footer data |
103
107
  | `footerSegments.capitalizeProviderName` | Boolean | Capitalizes the first character of the provider name in the footer; set it to `false` to keep the provider's original casing |
108
+ | `footerSegments.nerdIconSpacing` | Boolean | Adds an extra blank cell after Nerd Font icons in the footer; defaults to `false` |
104
109
  | `telemetry` | Boolean flags | Enables telemetry and its individual measurements |
105
110
  | `thinkingPeek.lines` | `0`, `1`, `2` | Off, one-line, or two-line hidden thinking preview |
106
111
 
107
- `sessionName` appears only when the session has a name. `hostname` shows the short host name (first label of the machine's host name, e.g. `mba` from `mba.example.com`) with a server icon. `gitCommit` shows the short hash and tag in detached HEAD state. Disabling `extensionStatuses` hides the entire extension status line, including MCP status. Each status keeps the colours its extension applied with `ctx.ui.theme.fg()`. A status without colours renders in the muted theme colour.
112
+ `sessionName` appears only when the session has a name. `hostname` shows the short host name (first label of the machine's host name, e.g. `mba` from `mba.example.com`) with a server icon. In detached HEAD state, `gitBranch` shows `HEAD` with the short commit hash; `gitCommit` appends the tag pointing at that commit and only takes effect while `gitBranch` is on. Disabling `extensionStatuses` hides the entire extension status line, including MCP status. Each status keeps the colours its extension applied with `ctx.ui.theme.fg()`. A status without colours renders in the muted theme colour.
108
113
 
109
114
  With `inlineFooter` enabled, the two normal Footer rows are rendered inside the editor frame to save vertical space. The top border places the Git branch on the left and CWD first in the right-hand group; the session title appears on the left too when `sessionName` is enabled. The Header and extension status rows remain separate; narrow terminals truncate lower-priority Footer data first, keeping the right-hand statistics and the border corner.
110
115
 
@@ -118,6 +123,8 @@ After each complete agent run, pi-open-tui shows one transient result. Tool-call
118
123
 
119
124
  TPS is calculated from all provider-reported assistant output tokens divided by the total generation time across the run. Timing starts at `turn_start` and ends at the assistant `message_end`, so it includes TTFT, hidden reasoning, buffering, and stalls; tool execution between turns is excluded. Runs without output tokens or measurable generation time show `TPS —`.
120
125
 
126
+ While a response streams, the working timer also shows the current estimated speed, for example `working 12.3s · ~54 tok/s`. The `~` marks it as an estimate: it is the rate over the last 2 seconds of streamed text, thinking, and tool-call arguments, estimated at about 4 characters per token for Latin text and code and 1 token per CJK character. It drops toward zero during stalls and stays dimmed at its last value during tool execution and between messages. It is controlled by **Footer → Live TPS** (`footerSegments.liveTps`), independently of the telemetry notification. It disappears when the run ends; the exact TPS above is the authoritative value.
127
+
121
128
  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.
122
129
 
123
130
  ## Thinking peek
package/README.zh-CN.md CHANGED
@@ -49,6 +49,8 @@ pi -e npm:pi-open-tui
49
49
 
50
50
  如果已经安装字体,但 `auto` 仍选择 ASCII,请手动切换为 `nerd`。使用 VS Code、Windows Terminal 等应用时,只在操作系统中安装字体还不够,还需要在对应的终端配置中选中该字体。如果 SSH 客户端终端确实安装了 Nerd Font 并希望在 SSH 下使用完整图标,请将模式显式设为 `nerd`。
51
51
 
52
+ 如果较宽的 Nerd Font 图标占用了图标与文字之间的间隔(例如在 Kitty 中),可开启 `footerSegments.nerdIconSpacing`(`/open-tui` 中的 **底栏 → Nerd 图标后额外留空**)。开启后底栏会在 Nerd Font 图标后额外预留一格空白,包括运行环境图标,常规底栏和内嵌底栏的宽度计算都包含这格留白;Unicode/ASCII 图标与紧凑的 Git 状态标记不受影响。Kitty 用户也可以在 `kitty.conf` 中设置 `narrow_symbols U+E000-U+F8FF 1`。
53
+
52
54
  ## 配置
53
55
 
54
56
  运行 `/open-tui` 打开设置窗口,其中包含**常规**、**外观**、**底栏**和**遥测**四个页面。设置保存在 `~/.pi/agent/open-tui.json`:
@@ -73,8 +75,10 @@ pi -e npm:pi-open-tui
73
75
  "context": true,
74
76
  "tokens": true,
75
77
  "cost": true,
78
+ "liveTps": true,
76
79
  "extensionStatuses": true,
77
- "capitalizeProviderName": true
80
+ "capitalizeProviderName": true,
81
+ "nerdIconSpacing": false
78
82
  },
79
83
  "telemetry": {
80
84
  "enabled": true,
@@ -101,10 +105,11 @@ pi -e npm:pi-open-tui
101
105
  | `icons.mode` | `auto`、`nerd`、`unicode`、`ascii` | 控制底栏和遥测通知使用的图标 |
102
106
  | `footerSegments` | 布尔开关 | 分别控制底栏中的各项数据 |
103
107
  | `footerSegments.capitalizeProviderName` | 布尔开关 | 将底栏中提供商名称的首字母大写;设为 `false` 时保留原始大小写 |
108
+ | `footerSegments.nerdIconSpacing` | 布尔开关 | 在底栏 Nerd Font 图标后额外留一格空白;默认 `false` |
104
109
  | `telemetry` | 布尔开关 | 控制遥测总开关和各项指标 |
105
110
  | `thinkingPeek.lines` | `0`、`1`、`2` | 关闭、单行或双行思考预览 |
106
111
 
107
- `sessionName` 仅在会话有名称时显示;`hostname` 会显示主机名的短名称(主机名的第一个标签,例如从 `mba.example.com` 显示为 `mba`),并使用服务器图标;`gitCommit` 会在 detached HEAD 状态下显示短哈希和标签;关闭 `extensionStatuses` 会隐藏整行扩展状态,其中也包括 MCP 状态。每条状态会保留其扩展通过 `ctx.ui.theme.fg()` 设置的颜色;未设置颜色的状态使用 muted 主题色显示。
112
+ `sessionName` 仅在会话有名称时显示;`hostname` 会显示主机名的短名称(主机名的第一个标签,例如从 `mba.example.com` 显示为 `mba`),并使用服务器图标;处于分离 HEAD(detached HEAD)状态时,`gitBranch` 会显示 `HEAD` 和短提交哈希,`gitCommit` 会在其后追加指向该提交的标签,且仅在开启 `gitBranch` 时生效;关闭 `extensionStatuses` 会隐藏整行扩展状态,其中也包括 MCP 状态。每条状态会保留其扩展通过 `ctx.ui.theme.fg()` 设置的颜色;未设置颜色的状态使用 muted 主题色显示。
108
113
 
109
114
  开启 `inlineFooter` 后,两条常规 Footer 信息行会移入编辑器边框,从而节省垂直空间。顶部边框左侧显示 Git 分支,右侧信息组以当前目录开头;开启 `sessionName` 时,会话标题也会显示在左侧。Header 和扩展状态行保持独立显示;终端较窄时优先截断低优先级 Footer 数据,保留右侧统计信息和边框角。
110
115
 
@@ -118,6 +123,8 @@ pi -e npm:pi-open-tui
118
123
 
119
124
  TPS 的计算方式是:将本次运行中服务商报告的全部 Assistant 输出 Token,除以各个生成轮次的总耗时。计时范围从 `turn_start` 到 Assistant 的 `message_end`,包含 TTFT、隐藏推理、缓冲和停顿,但不包含轮次之间的工具执行时间。没有输出 Token 或无法测得生成时间时,会显示 `TPS —`。
120
125
 
126
+ 回复流式输出时,工作计时器还会显示当前的估算速度,例如 `working 12.3s · ~54 tok/s`。`~` 表示估算值:它是最近 2 秒内流式输出的正文、思考内容和工具调用参数的速率,按拉丁文本和代码约 4 个字符 1 个 Token、中日韩字符每字 1 个 Token 估算。停顿时数值会逐渐降向零;工具执行期间和消息之间以暗色保留上一个值。由**底栏 → 实时 TPS**(`footerSegments.liveTps`)控制,与遥测通知的开关互不影响。运行结束后不再显示,以上面的精确 TPS 为准。
127
+
121
128
  `$ / M` 表示根据 `usage.cost.total` 得到的模型标价速率,不是底栏中的会话累计费用。所有遥测字段都可以在**遥测**页单独开关。
122
129
 
123
130
  ## 思考预览
@@ -20,8 +20,10 @@ export interface FooterSegments {
20
20
  context: boolean;
21
21
  tokens: boolean;
22
22
  cost: boolean;
23
+ liveTps: boolean;
23
24
  extensionStatuses: boolean;
24
25
  capitalizeProviderName: boolean;
26
+ nerdIconSpacing: boolean;
25
27
  }
26
28
 
27
29
  export interface TelemetryConfig {
@@ -70,8 +72,10 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
70
72
  context: true,
71
73
  tokens: true,
72
74
  cost: true,
75
+ liveTps: true,
73
76
  extensionStatuses: true,
74
77
  capitalizeProviderName: true,
78
+ nerdIconSpacing: false,
75
79
  },
76
80
  telemetry: {
77
81
  enabled: true,
@@ -96,6 +100,18 @@ function normalizeThinkingPeekLines(value: unknown): ThinkingPeekLines {
96
100
  return value === 0 || value === 1 || value === 2 ? value : DEFAULT_CONFIG.thinkingPeek.lines;
97
101
  }
98
102
 
103
+ const ICON_MODES: IconMode[] = ["auto", "nerd", "unicode", "ascii"];
104
+
105
+ // Non-boolean flags (e.g. "false" strings) fall back to defaults instead of reading as truthy.
106
+ function normalizeFlags<T extends object>(value: unknown, defaults: T): T {
107
+ const source = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
108
+ const result: Record<string, unknown> = { ...source };
109
+ for (const [key, fallback] of Object.entries(defaults)) {
110
+ if (typeof result[key] !== "boolean") result[key] = fallback;
111
+ }
112
+ return result as T;
113
+ }
114
+
99
115
  function deepMerge<T>(base: T, override: unknown): T {
100
116
  if (typeof base !== "object" || base === null || Array.isArray(base)) {
101
117
  return (override as T) ?? base;
@@ -141,9 +157,17 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
141
157
  const raw = readFileSync(path, "utf8");
142
158
  const parsed: unknown = JSON.parse(raw);
143
159
  const config = deepMerge(DEFAULT_CONFIG, parsed);
160
+ if (typeof config.enabled !== "boolean") {
161
+ config.enabled = DEFAULT_CONFIG.enabled;
162
+ }
144
163
  if (typeof config.inlineFooter !== "boolean") {
145
164
  config.inlineFooter = DEFAULT_CONFIG.inlineFooter;
146
165
  }
166
+ config.footerSegments = normalizeFlags(config.footerSegments, DEFAULT_CONFIG.footerSegments);
167
+ config.telemetry = normalizeFlags(config.telemetry, DEFAULT_CONFIG.telemetry);
168
+ if (typeof config.icons !== "object" || config.icons === null || !ICON_MODES.includes(config.icons.mode)) {
169
+ config.icons = structuredClone(DEFAULT_CONFIG.icons);
170
+ }
147
171
  if (config.settingsLanguage !== "en" && config.settingsLanguage !== "zh") {
148
172
  config.settingsLanguage = DEFAULT_CONFIG.settingsLanguage;
149
173
  }
@@ -6,6 +6,7 @@ import type { IconGlyphs } from "./icons.ts";
6
6
  import { resolveGlyphs, resolveIconMode, runtimeSymbol } from "./icons.ts";
7
7
  import type { GitStatus } from "./git.ts";
8
8
  import type { RuntimeInfo } from "./runtime.ts";
9
+ import type { LiveTps } from "./telemetry.ts";
9
10
  import {
10
11
  alignRight,
11
12
  basenamePath,
@@ -31,6 +32,15 @@ export function shortHostname(hostname: string): string {
31
32
  return hostname.split(".")[0] ?? "";
32
33
  }
33
34
 
35
+ // Kitty can use the blank cell after a wide Nerd icon to render the glyph.
36
+ // With footerSegments.nerdIconSpacing, reserve an extra cell before fitting
37
+ // footer segments. Keep Unicode/ASCII and Git flags compact.
38
+ function spaceFooterIcon(glyph: string): string {
39
+ return /[\uE000-\uF8FF\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/u.test(glyph)
40
+ ? `${glyph} `
41
+ : glyph;
42
+ }
43
+
34
44
  function renderBar(theme: Theme, pct: number, barWidth: number, ascii: boolean): string {
35
45
  const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
36
46
  const empty = barWidth - filled;
@@ -112,9 +122,11 @@ function renderRuntimeSegment(
112
122
  theme: Theme,
113
123
  runtime: RuntimeInfo | null,
114
124
  iconMode: OpenTuiConfig["icons"]["mode"],
125
+ spaceIcon: boolean,
115
126
  ): string {
116
127
  if (!runtime) return "";
117
- const symbol = theme.fg("success", runtimeSymbol(runtime.name, iconMode));
128
+ const glyph = runtimeSymbol(runtime.name, iconMode);
129
+ const symbol = theme.fg("success", spaceIcon ? spaceFooterIcon(glyph) : glyph);
118
130
  const version = runtime.version ? theme.fg("muted", runtime.version) : "";
119
131
  const label = [symbol, version].filter(Boolean).join(" ");
120
132
  return label;
@@ -213,6 +225,7 @@ function renderExtensionStatusLines(
213
225
  export interface FooterHooks {
214
226
  setRequestRender: (fn: (() => void) | undefined) => void;
215
227
  scheduleGitRefresh: () => void;
228
+ getLiveTps?: () => LiveTps | null;
216
229
  }
217
230
 
218
231
  interface FooterDataLike {
@@ -272,6 +285,7 @@ function renderFooterContent(
272
285
  theme: Theme,
273
286
  footerData: FooterDataLike,
274
287
  width: number,
288
+ getLiveTps: () => LiveTps | null,
275
289
  ): { mainLines: [string, string]; inlineLines: InlineFooterLines; extensionLines: string[] } {
276
290
  if (width <= 0) {
277
291
  return {
@@ -286,8 +300,13 @@ function renderFooterContent(
286
300
 
287
301
  const state = getState();
288
302
  const config = getConfig();
289
- const glyphs = resolveGlyphs(config.icons.mode);
290
303
  const segments = config.footerSegments;
304
+ const glyphs = { ...resolveGlyphs(config.icons.mode) };
305
+ if (segments.nerdIconSpacing) {
306
+ for (const key of Object.keys(glyphs) as (keyof IconGlyphs)[]) {
307
+ glyphs[key] = spaceFooterIcon(glyphs[key]);
308
+ }
309
+ }
291
310
  const meta = getModelMeta();
292
311
  const totals = getUsageTotals(ctx);
293
312
 
@@ -340,7 +359,7 @@ function renderFooterContent(
340
359
  inlineTopLeftParts.push(leftParts.at(-1)!);
341
360
  }
342
361
  if (segments.runtime) {
343
- const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
362
+ const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode, segments.nerdIconSpacing);
344
363
  if (runtimeSeg) {
345
364
  leftParts.push({ text: runtimeSeg, priority: 4 });
346
365
  inlineTopRightParts.push(leftParts.at(-1)!);
@@ -348,7 +367,14 @@ function renderFooterContent(
348
367
  }
349
368
  const timerSeg = renderTimerSegment(theme, state, glyphs);
350
369
  if (timerSeg) {
351
- leftParts.push({ text: timerSeg, priority: 1 });
370
+ const liveTps = state.workingSince !== undefined && segments.liveTps
371
+ ? getLiveTps()
372
+ : null;
373
+ const text = liveTps === null
374
+ ? timerSeg
375
+ : `${timerSeg} ${theme.fg("dim", "·")} ${theme.fg(liveTps.stale ? "dim" : "accent", `${glyphs.speed} ~${Math.round(liveTps.tps)} tok/s`)}`;
376
+ // Narrow widths shed the live TPS before the timer itself.
377
+ leftParts.push({ text, compactText: liveTps === null ? undefined : timerSeg, priority: 1 });
352
378
  inlineTopRightParts.push(leftParts.at(-1)!);
353
379
  }
354
380
 
@@ -427,6 +453,7 @@ export function installFooter(
427
453
  theme,
428
454
  footerData,
429
455
  width,
456
+ hooks.getLiveTps ?? (() => null),
430
457
  );
431
458
 
432
459
  renderInline = (width) => getContent(width).inlineLines;
@@ -77,11 +77,13 @@ export async function readGitStatus(
77
77
  status.branch = undefined;
78
78
  status.commit = { oid: null, detached: true, tag: null };
79
79
  } else {
80
- const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(ahead|behind) (\d+)\])?$/);
80
+ const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(.*)\])?$/);
81
81
  if (branchMatch) {
82
82
  status.branch = branchMatch[1];
83
- if (branchMatch[3] === "ahead") status.ahead = parseInt(branchMatch[4]!, 10);
84
- if (branchMatch[3] === "behind") status.behind = parseInt(branchMatch[4]!, 10);
83
+ const ahead = branchMatch[3]?.match(/\bahead (\d+)\b/);
84
+ const behind = branchMatch[3]?.match(/\bbehind (\d+)\b/);
85
+ if (ahead) status.ahead = parseInt(ahead[1]!, 10);
86
+ if (behind) status.behind = parseInt(behind[1]!, 10);
85
87
  }
86
88
  }
87
89
  continue;
@@ -103,7 +105,7 @@ export async function readGitStatus(
103
105
  const x = line[0]!;
104
106
  const y = line[1]!;
105
107
 
106
- if (x === "U" || y === "U" || (x === "C" && y === "C")) status.conflicted++;
108
+ if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) status.conflicted++;
107
109
  else if (x === "?" && y === "?") status.untracked++;
108
110
  else if (x === "R") status.renamed++;
109
111
  else if (x === "D" || y === "D") status.deleted++;
@@ -143,6 +143,7 @@ export default function (pi: ExtensionAPI) {
143
143
  scheduleGitRefresh: () => {
144
144
  void scheduleGitRefresh(ctx);
145
145
  },
146
+ getLiveTps: () => turnTelemetry.getLiveTps(),
146
147
  },
147
148
  );
148
149
  cleanupFooter = footer.cleanup;
@@ -178,7 +179,8 @@ export default function (pi: ExtensionAPI) {
178
179
  const scheduleGitRefresh = async (ctx: ExtensionContext) => {
179
180
  if (!sessionLifecycle.isCurrent()) return;
180
181
  const segs = config.footerSegments;
181
- if (!segs.gitBranch && !segs.gitStatus && !segs.gitCommit) {
182
+ // gitCommit only appends a tag to the gitBranch segment, so it never needs git on its own.
183
+ if (!segs.gitBranch && !segs.gitStatus) {
182
184
  state.git = emptyGitStatus();
183
185
  requestFooterRender?.();
184
186
  return;
@@ -186,8 +188,8 @@ export default function (pi: ExtensionAPI) {
186
188
  const generation = sessionLifecycle.currentGeneration();
187
189
  const cwd = ctx.cwd;
188
190
  const git = await readGitStatus(cwd, {
189
- readCommit: true,
190
- readTag: segs.gitCommit,
191
+ readCommit: segs.gitBranch,
192
+ readTag: segs.gitBranch && segs.gitCommit,
191
193
  readCounts: segs.gitStatus,
192
194
  });
193
195
  if (!sessionLifecycle.isCurrent(generation)) return;
@@ -399,7 +401,7 @@ export default function (pi: ExtensionAPI) {
399
401
  if (lastCtx) {
400
402
  pendingUiChange = getPendingUiChange(newConfig.enabled, active);
401
403
  }
402
- const gitNeeded = newConfig.footerSegments.gitBranch || newConfig.footerSegments.gitStatus || newConfig.footerSegments.gitCommit;
404
+ const gitNeeded = newConfig.footerSegments.gitBranch || newConfig.footerSegments.gitStatus;
403
405
  if (lastCtx && gitNeeded) {
404
406
  void scheduleGitRefresh(lastCtx);
405
407
  } else {
@@ -28,7 +28,7 @@ const COPY = {
28
28
  hint: "Tab/Shift+Tab/←/→: tabs · ↑/↓: move · Enter/Space: change · Esc/q: close",
29
29
  labels: {
30
30
  enabled: "Enabled",
31
- inlineFooter: "Inline footer",
31
+ inlineFooter: "Footer in editor border",
32
32
  thinkingPeek: "Thinking peek",
33
33
  language: "Language",
34
34
  cursorStyle: "Cursor style",
@@ -38,17 +38,19 @@ const COPY = {
38
38
  sessionName: "Session name",
39
39
  gitBranch: "Git branch",
40
40
  gitStatus: "Git status",
41
- gitCommit: "Git commit (detached)",
42
- runtime: "Runtime",
43
- context: "Context bar",
44
- tokens: "Tokens",
45
- cost: "Cost",
41
+ gitCommit: "Git tag (detached HEAD)",
42
+ runtime: "Project runtime",
43
+ context: "Context usage",
44
+ tokens: "Session tokens",
45
+ cost: "Session cost",
46
46
  extensionStatuses: "Extension status line",
47
47
  capitalizeProviderName: "Capitalize provider name",
48
+ nerdIconSpacing: "Extra space after Nerd icons",
49
+ liveTps: "Live TPS",
48
50
  totalDuration: "Total duration",
49
- tokenCounts: "Token counts",
51
+ tokenCounts: "Run tokens",
50
52
  stallDetails: "Stall details",
51
- costRate: "Cost rate",
53
+ costRate: "Price per 1M tokens",
52
54
  },
53
55
  values: {
54
56
  on: "On",
@@ -61,11 +63,11 @@ const COPY = {
61
63
  },
62
64
  zh: {
63
65
  title: "Open TUI 设置",
64
- tabs: { features: "常规", icons: "外观", segments: "Footer", telemetry: "遥测" },
66
+ tabs: { features: "常规", icons: "外观", segments: "底栏", telemetry: "遥测" },
65
67
  hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · Esc/q:关闭",
66
68
  labels: {
67
69
  enabled: "启用",
68
- inlineFooter: "内联底栏",
70
+ inlineFooter: "底栏嵌入输入框",
69
71
  thinkingPeek: "思考预览",
70
72
  language: "语言",
71
73
  cursorStyle: "光标样式",
@@ -75,17 +77,19 @@ const COPY = {
75
77
  sessionName: "会话名",
76
78
  gitBranch: "Git 分支",
77
79
  gitStatus: "Git 状态",
78
- gitCommit: "Git 提交(分离 HEAD)",
79
- runtime: "运行环境",
80
- context: "上下文栏",
81
- tokens: "Token",
82
- cost: "费用",
80
+ gitCommit: "Git 标签(分离 HEAD)",
81
+ runtime: "项目运行环境",
82
+ context: "上下文用量",
83
+ tokens: "会话 Token",
84
+ cost: "会话费用",
83
85
  extensionStatuses: "扩展状态行",
84
86
  capitalizeProviderName: "提供商名称首字母大写",
87
+ nerdIconSpacing: "Nerd 图标后额外留空",
88
+ liveTps: "实时 TPS",
85
89
  totalDuration: "总耗时",
86
- tokenCounts: "Token 数量",
90
+ tokenCounts: "单次运行 Token",
87
91
  stallDetails: "停顿详情",
88
- costRate: "费用速率",
92
+ costRate: "每百万 Token 单价",
89
93
  },
90
94
  values: {
91
95
  on: "开启",
@@ -183,8 +187,10 @@ function buildSegmentsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
183
187
  { id: "context", label: copy.labels.context, currentValue: flag(segs.context) },
184
188
  { id: "tokens", label: copy.labels.tokens, currentValue: flag(segs.tokens) },
185
189
  { id: "cost", label: copy.labels.cost, currentValue: flag(segs.cost) },
190
+ { id: "liveTps", label: copy.labels.liveTps, currentValue: flag(segs.liveTps) },
186
191
  { id: "extensionStatuses", label: copy.labels.extensionStatuses, currentValue: flag(segs.extensionStatuses) },
187
192
  { id: "capitalizeProviderName", label: copy.labels.capitalizeProviderName, currentValue: flag(segs.capitalizeProviderName) },
193
+ { id: "nerdIconSpacing", label: copy.labels.nerdIconSpacing, currentValue: flag(segs.nerdIconSpacing) },
188
194
  ];
189
195
  }
190
196
 
@@ -14,6 +14,10 @@ import { resolveGlyphs } from "./icons.ts";
14
14
  import { finiteOrZero, fmtTokens, formatDuration, formatInputBreakdown } from "./utils.ts";
15
15
 
16
16
  const STALL_THRESHOLD_MS = 1000;
17
+ // Live TPS is the rate over this trailing window, so it tracks current speed.
18
+ const LIVE_TPS_WINDOW_MS = 2000;
19
+ // CJK/kana/hangul/fullwidth: roughly one token per character in common tokenizers.
20
+ const CJK_CHARS = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g;
17
21
 
18
22
  type TelemetryEvent =
19
23
  | AgentStartEvent
@@ -31,6 +35,9 @@ interface MessageTiming {
31
35
  lastUpdateMs: number;
32
36
  firstOutputMs: number | null;
33
37
  inStall: boolean;
38
+ estimatedTokens: number;
39
+ /** Cumulative estimate per delta; keeps one sample before the window as baseline. */
40
+ samples: { ms: number; tokens: number }[];
34
41
  }
35
42
 
36
43
  interface TurnTiming {
@@ -59,10 +66,22 @@ export interface TurnTelemetry {
59
66
  measurementMs: number | null;
60
67
  }
61
68
 
69
+ export interface LiveTps {
70
+ tps: number;
71
+ /** True between messages, when the value is the previous message's last rate. */
72
+ stale: boolean;
73
+ }
74
+
62
75
  function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
63
76
  return message.role === "assistant";
64
77
  }
65
78
 
79
+ /** ~4 chars/token for Latin text and code, ~1 token per CJK character. */
80
+ export function estimateDeltaTokens(delta: string): number {
81
+ const cjk = delta.match(CJK_CHARS)?.length ?? 0;
82
+ return cjk + (delta.length - cjk) / 4;
83
+ }
84
+
66
85
  function round(value: number, decimals: number): number {
67
86
  const factor = 10 ** decimals;
68
87
  return Math.round(value * factor) / factor;
@@ -73,17 +92,41 @@ export class TurnTelemetryTracker {
73
92
  private turn: TurnTiming | undefined;
74
93
  private agentStartMs: number | null = null;
75
94
  private agentTurns: TurnTelemetry[] = [];
95
+ private lastLiveTps: number | null = null;
76
96
 
77
97
  constructor(now: () => number = () => performance.now()) {
78
98
  this.now = now;
79
99
  }
80
100
 
101
+ /**
102
+ * Estimated output rate of the streaming message over the trailing window.
103
+ * Between messages of a run (tools, TTFT) the last rate is kept as `stale`.
104
+ */
105
+ getLiveTps(): LiveTps | null {
106
+ const tps = this.windowTps();
107
+ if (tps !== null) return { tps, stale: false };
108
+ return this.lastLiveTps === null ? null : { tps: this.lastLiveTps, stale: true };
109
+ }
110
+
111
+ private windowTps(): number | null {
112
+ const samples = this.turn?.currentMessage?.samples;
113
+ if (!samples || samples.length < 2) return null;
114
+ const first = samples[0]!;
115
+ const now = this.now();
116
+ // Samples are only pruned on new deltas, so a stall longer than the window means no recent output.
117
+ if (now - samples.at(-1)!.ms >= LIVE_TPS_WINDOW_MS) return 0;
118
+ const elapsedMs = now - first.ms;
119
+ if (elapsedMs <= 0) return null;
120
+ return round((samples.at(-1)!.tokens - first.tokens) / (elapsedMs / 1000), 1);
121
+ }
122
+
81
123
  handle(event: TelemetryEvent): TurnTelemetry | undefined {
82
124
  switch (event.type) {
83
125
  case "agent_start":
84
126
  if (this.agentStartMs === null) {
85
127
  this.agentStartMs = this.now();
86
128
  this.agentTurns = [];
129
+ this.lastLiveTps = null;
87
130
  }
88
131
  return;
89
132
  case "agent_settled":
@@ -126,6 +169,8 @@ export class TurnTelemetryTracker {
126
169
  lastUpdateMs: now,
127
170
  firstOutputMs: null,
128
171
  inStall: false,
172
+ estimatedTokens: 0,
173
+ samples: [],
129
174
  };
130
175
  }
131
176
 
@@ -143,6 +188,11 @@ export class TurnTelemetryTracker {
143
188
  if (!turn || !current || !isAssistantMessage(message)) return;
144
189
 
145
190
  const now = this.now();
191
+ current.estimatedTokens += estimateDeltaTokens(streamEvent.delta);
192
+ const samples = current.samples;
193
+ samples.push({ ms: now, tokens: current.estimatedTokens });
194
+ while (samples.length > 2 && samples[1]!.ms <= now - LIVE_TPS_WINDOW_MS) samples.shift();
195
+
146
196
  if (current.firstOutputMs === null) {
147
197
  current.firstOutputMs = now;
148
198
  turn.firstTokenMs ??= now;
@@ -172,6 +222,7 @@ export class TurnTelemetryTracker {
172
222
  if (current.firstOutputMs === null && finiteOrZero(message.usage?.output) > 0) {
173
223
  turn.firstTokenMs ??= endMs;
174
224
  }
225
+ this.lastLiveTps = this.windowTps() ?? this.lastLiveTps;
175
226
  turn.currentMessage = null;
176
227
  }
177
228
  turn.messages.push(message);
@@ -234,6 +285,7 @@ export class TurnTelemetryTracker {
234
285
  const turns = this.agentTurns;
235
286
  this.agentStartMs = null;
236
287
  this.agentTurns = [];
288
+ this.lastLiveTps = null;
237
289
  if (startMs === null || turns.length === 0) return;
238
290
 
239
291
  const outputTokens = turns.reduce((sum, turn) => sum + turn.outputTokens, 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
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": {