pi-open-tui 0.3.8 → 0.3.10

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
@@ -19,9 +19,9 @@ A polished terminal interface for the [Pi](https://pi.dev) coding agent. It brin
19
19
 
20
20
  ## Requirements
21
21
 
22
- - Pi 0.80 or later
22
+ - Pi 0.85 or later
23
23
  - A terminal with UTF-8 and color support
24
- - A [Nerd Font](https://www.nerdfonts.com/font-downloads) for the full icon set (optional; ASCII icons are built in)
24
+ - A [Nerd Font](https://www.nerdfonts.com/font-downloads) for the full icon set (optional; portable Unicode icons are built in)
25
25
 
26
26
  ## Install
27
27
 
@@ -44,10 +44,11 @@ Download any patched font from the official [Nerd Fonts downloads page](https://
44
44
  The default `auto` mode checks the terminal environment, not the installed font file. It uses Nerd Font icons in interactive UTF-8 TTYs, including terminals running through a runner or subshell. If icons appear as boxes or incorrect symbols, open `/open-tui` and choose one of these modes under **Appearance**:
45
45
 
46
46
  - `nerd`: force Nerd Font icons after configuring a Nerd Font in the terminal
47
+ - `unicode`: portable Unicode icons (folder, branch, laptop, bulb, plug, hourglass, ...) that render without a patched font; emoji glyphs use the terminal's emoji fallback
47
48
  - `ascii`: use plain-text icons with no patched font required
48
- - `auto`: use Nerd Font icons in interactive UTF-8 TTYs; use ASCII for non-interactive output, `TERM=dumb`, or an explicitly non-UTF-8 locale
49
+ - `auto`: use Nerd Font icons in interactive UTF-8 TTYs; use portable Unicode icons for SSH sessions (the client terminal controls the font and usually has no Nerd Font); use ASCII for non-interactive output, `TERM=dumb`, or an explicitly non-UTF-8 locale
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.
51
+ 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
52
 
52
53
  ## Configuration
53
54
 
@@ -102,7 +103,7 @@ Key options:
102
103
  | `inlineFooter` | `true`, `false` | Moves the two main Footer rows into the editor's top and bottom borders; defaults to `false`. Extension status rows remain below the editor |
103
104
  | `cursorStyle` | `block`, `bar`, `underline` | `bar` and `underline` require terminal cursor-shape support |
104
105
  | `fullscreen.wheelScrollLines` | `1`-`10` | Lines scrolled per mouse-wheel notch in fullscreen mode; defaults to `4`. In `/open-tui`, press Enter on this item and type a number (values are clamped to `1`-`10`) |
105
- | `icons.mode` | `auto`, `nerd`, `ascii` | Controls footer and telemetry icons |
106
+ | `icons.mode` | `auto`, `nerd`, `unicode`, `ascii` | Controls footer and telemetry icons |
106
107
  | `footerSegments` | Boolean flags | Shows or hides individual footer data |
107
108
  | `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
109
  | `telemetry` | Boolean flags | Enables telemetry and its individual measurements |
package/README.zh-CN.md CHANGED
@@ -19,9 +19,9 @@
19
19
 
20
20
  ## 环境要求
21
21
 
22
- - Pi 0.80 或更高版本
22
+ - Pi 0.85 或更高版本
23
23
  - 支持 UTF-8 和彩色输出的终端
24
- - 使用完整图标集时需要 [Nerd Font](https://www.nerdfonts.com/font-downloads)(可选;内置 ASCII 图标)
24
+ - 使用完整图标集时需要 [Nerd Font](https://www.nerdfonts.com/font-downloads)(可选;内置可移植 Unicode 图标)
25
25
 
26
26
  ## 安装
27
27
 
@@ -44,10 +44,11 @@ pi -e npm:pi-open-tui
44
44
  默认的 `auto` 模式检查终端环境,而不是已安装的字体文件。在支持 UTF-8 的交互式 TTY 中,它会使用 Nerd Font 图标,即使 Pi 运行在 runner 或子 shell 中。如果图标显示为方框、乱码或错误符号,请打开 `/open-tui`,在**外观**页选择合适的模式:
45
45
 
46
46
  - `nerd`:终端已配置 Nerd Font 时,强制使用 Nerd Font 图标
47
+ - `unicode`:可移植 Unicode 图标(文件夹、分支、笔记本电脑、灯泡、插头、沙漏等),无需修补字体;emoji 字形通过终端的 emoji 回退渲染
47
48
  - `ascii`:使用纯文本图标,无需安装修补字体
48
- - `auto`:在支持 UTF-8 的交互式 TTY 中使用 Nerd Font 图标;非交互式输出、`TERM=dumb` 或明确配置为非 UTF-8 的 locale 使用 ASCII
49
+ - `auto`:在支持 UTF-8 的交互式 TTY 中使用 Nerd Font 图标;SSH 会话使用可移植 Unicode 图标(字体由客户端终端决定,通常没有 Nerd Font);非交互式输出、`TERM=dumb` 或明确配置为非 UTF-8 的 locale 使用 ASCII
49
50
 
50
- 如果已经安装字体,但 `auto` 仍选择 ASCII,请手动切换为 `nerd`。使用 VS Code、Windows Terminal 等应用时,只在操作系统中安装字体还不够,还需要在对应的终端配置中选中该字体。
51
+ 如果已经安装字体,但 `auto` 仍选择 ASCII,请手动切换为 `nerd`。使用 VS Code、Windows Terminal 等应用时,只在操作系统中安装字体还不够,还需要在对应的终端配置中选中该字体。如果 SSH 客户端终端确实安装了 Nerd Font 并希望在 SSH 下使用完整图标,请将模式显式设为 `nerd`。
51
52
 
52
53
  ## 配置
53
54
 
@@ -102,7 +103,7 @@ pi -e npm:pi-open-tui
102
103
  | `inlineFooter` | `true`、`false` | 将两条主要 Footer 信息行移入编辑器上下边框以节省垂直空间,默认关闭;扩展状态行仍显示在编辑器外 |
103
104
  | `cursorStyle` | `block`、`bar`、`underline` | `bar` 和 `underline` 需要终端支持光标形状转义序列 |
104
105
  | `fullscreen.wheelScrollLines` | `1`-`10` | 全屏模式下滚轮每格滚动的行数,默认值为 `4`;`/open-tui` 中在该项上按 Enter 后直接输入数字(超出范围会自动钳制到 `1`-`10`) |
105
- | `icons.mode` | `auto`、`nerd`、`ascii` | 控制底栏和遥测通知使用的图标 |
106
+ | `icons.mode` | `auto`、`nerd`、`unicode`、`ascii` | 控制底栏和遥测通知使用的图标 |
106
107
  | `footerSegments` | 布尔开关 | 分别控制底栏中的各项数据 |
107
108
  | `footerSegments.capitalizeProviderName` | 布尔开关 | 将底栏中提供商名称的首字母大写;设为 `false` 时保留原始大小写 |
108
109
  | `telemetry` | 布尔开关 | 控制遥测总开关和各项指标 |
@@ -4,7 +4,7 @@ import {
4
4
  type ExtensionContext,
5
5
  type KeybindingsManager,
6
6
  } from "@earendil-works/pi-coding-agent";
7
- import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
7
+ import type { EditorTheme, TuiMouseEvent, TuiMouseEventResult, TUI } from "@earendil-works/pi-tui";
8
8
  import { CURSOR_MARKER, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
9
  import type { CursorStyle } from "./config.ts";
10
10
  import {
@@ -24,6 +24,8 @@ const CURSOR_STYLE_SEQUENCES: Partial<Record<CursorStyle, string>> = {
24
24
  underline: "\x1b[4 q",
25
25
  };
26
26
  const DEFAULT_CURSOR_STYLE_SEQUENCE = "\x1b[0 q";
27
+ const EDITOR_FRAME_LEFT_INSET = 2;
28
+ const EDITOR_FRAME_HORIZONTAL_CHROME = EDITOR_FRAME_LEFT_INSET * 2;
27
29
 
28
30
  interface WorkingStatusIndicator {
29
31
  renderInBorder(width: number): string;
@@ -221,6 +223,16 @@ export class OpenTuiEditor extends CustomEditor {
221
223
  super.setPaddingX(0);
222
224
  }
223
225
 
226
+ override handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
227
+ if (event.width < EDITOR_FRAME_HORIZONTAL_CHROME) return super.handleMouse(event);
228
+
229
+ return super.handleMouse({
230
+ ...event,
231
+ x: event.x - EDITOR_FRAME_LEFT_INSET,
232
+ width: event.width - EDITOR_FRAME_HORIZONTAL_CHROME,
233
+ });
234
+ }
235
+
224
236
  setWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): void {
225
237
  this.embeddedWorkingStatusIndicator = indicator;
226
238
  this.tui.requestRender();
@@ -262,12 +274,12 @@ export class OpenTuiEditor extends CustomEditor {
262
274
  }
263
275
 
264
276
  render(width: number): string[] {
265
- if (width < 4) return this.renderBase(width);
277
+ if (width < EDITOR_FRAME_HORIZONTAL_CHROME) return this.renderBase(width);
266
278
 
267
279
  const rail = this.getRail();
268
280
  const borderPaint = this.getBorder;
269
281
  // ponytail: 1-char rail + 1-char gap on each side = 4 chars of chrome.
270
- const innerWidth = Math.max(0, width - 4);
282
+ const innerWidth = Math.max(0, width - EDITOR_FRAME_HORIZONTAL_CHROME);
271
283
  const baseLines = this.renderBase(innerWidth);
272
284
  const bottomIdx = findBottomBorderIndex(baseLines);
273
285
 
@@ -1,4 +1,4 @@
1
- export type IconMode = "auto" | "nerd" | "ascii";
1
+ export type IconMode = "auto" | "nerd" | "unicode" | "ascii";
2
2
 
3
3
  export interface IconGlyphs {
4
4
  cwd: string;
@@ -94,6 +94,47 @@ const ASCII_GLYPHS: IconGlyphs = {
94
94
  deleted: "x",
95
95
  };
96
96
 
97
+ // Portable Unicode icon set for terminals without a Nerd Font. Keeps the
98
+ // footer icon-like instead of dropping to letters: emoji glyphs (folder,
99
+ // branch, laptop, bulb, floppy, plug, hourglass, bolt) render through the
100
+ // client terminal's emoji fallback at 2 columns, which matches
101
+ // pi-tui's visibleWidth for RGI emoji; the remaining symbols are
102
+ // single-width and covered by DejaVu Sans Mono, JetBrains Mono, Noto Sans
103
+ // Mono and Liberation Mono. Glyph choices are easy to adjust per slot.
104
+ const UNICODE_GLYPHS: IconGlyphs = {
105
+ cwd: "📁",
106
+ host: "⌂",
107
+ session: "🔖",
108
+ git: "🌿",
109
+ working: "◷",
110
+ done: "✓",
111
+ context: "≡",
112
+ model: "💻",
113
+ thinking: "💡",
114
+ input: "↑",
115
+ output: "↓",
116
+ cacheHit: "💾",
117
+ cost: "$",
118
+ speed: "⚡",
119
+ latency: "⌛",
120
+ stall: "⚠",
121
+ extensions: "🔌",
122
+ ahead: "↑",
123
+ behind: "↓",
124
+ diverged: "↕",
125
+ conflicted: "=",
126
+ stashed: "$",
127
+ modified: "!",
128
+ staged: "+",
129
+ untracked: "?",
130
+ renamed: "→",
131
+ deleted: "✗",
132
+ };
133
+
134
+ function isSshSession(): boolean {
135
+ return Boolean(process.env.SSH_TTY || process.env.SSH_CONNECTION);
136
+ }
137
+
97
138
  export function detectNerdFont(): boolean {
98
139
  // TTY and locale checks are the only reliable signals available here. The
99
140
  // terminal emulator owns font selection, so auto mode is optimistic once
@@ -103,14 +144,23 @@ export function detectNerdFont(): boolean {
103
144
  return locale === undefined || /utf-?8/i.test(locale);
104
145
  }
105
146
 
106
- export function resolveIconMode(mode: IconMode): "nerd" | "ascii" {
147
+ export function resolveIconMode(mode: IconMode): "nerd" | "unicode" | "ascii" {
107
148
  if (mode === "nerd") return "nerd";
108
149
  if (mode === "ascii") return "ascii";
109
- return detectNerdFont() ? "nerd" : "ascii";
150
+ if (mode === "unicode") return "unicode";
151
+ if (!detectNerdFont()) return "ascii";
152
+ // Auto cannot know whether the terminal that renders this session ships a
153
+ // Nerd Font (see #38). Local terminals usually do; SSH clients usually do
154
+ // not, because the font lives on the client device, so portable Unicode
155
+ // symbols are the safer default there. Terminals that do have a Nerd Font
156
+ // can opt back in with an explicit icons.mode of "nerd".
157
+ if (isSshSession()) return "unicode";
158
+ return "nerd";
110
159
  }
111
160
 
112
161
  export function resolveGlyphs(mode: IconMode): IconGlyphs {
113
162
  const resolved = resolveIconMode(mode);
163
+ if (resolved === "unicode") return UNICODE_GLYPHS;
114
164
  return resolved === "nerd" ? NERD_GLYPHS : ASCII_GLYPHS;
115
165
  }
116
166
 
@@ -185,6 +235,6 @@ const RUNTIME_ASCII_SYMBOLS: Record<string, string> = {
185
235
  };
186
236
 
187
237
  export function runtimeSymbol(name: string, mode: IconMode): string {
188
- if (resolveIconMode(mode) === "ascii") return RUNTIME_ASCII_SYMBOLS[name] ?? name;
238
+ if (resolveIconMode(mode) !== "nerd") return RUNTIME_ASCII_SYMBOLS[name] ?? name;
189
239
  return RUNTIME_SYMBOLS[name] ?? "";
190
240
  }
@@ -64,7 +64,7 @@ const COPY = {
64
64
  wheelLines: (count: number) => `${count} ${count === 1 ? "line" : "lines"} / notch`,
65
65
  wheelPrompt: (count: number) => `Wheel scroll lines per notch, 1-10 (current: ${count}). Enter: apply · Esc: cancel`,
66
66
  cursorStyles: { block: "Block", bar: "Bar", underline: "Underline" },
67
- icons: { auto: "Auto", nerd: "Nerd", ascii: "ASCII" },
67
+ icons: { auto: "Auto", nerd: "Nerd", unicode: "Unicode", ascii: "ASCII" },
68
68
  },
69
69
  },
70
70
  zh: {
@@ -104,7 +104,7 @@ const COPY = {
104
104
  wheelLines: (count: number) => `每格 ${count} 行`,
105
105
  wheelPrompt: (count: number) => `滚轮每格滚动行数(当前 ${count},范围 1-10),输入后 Enter 应用 · Esc 取消`,
106
106
  cursorStyles: { block: "块", bar: "竖线", underline: "下划线" },
107
- icons: { auto: "自动", nerd: "Nerd", ascii: "ASCII" },
107
+ icons: { auto: "自动", nerd: "Nerd", unicode: "Unicode", ascii: "ASCII" },
108
108
  },
109
109
  },
110
110
  } as const;
@@ -127,7 +127,7 @@ function toggleSetting(config: OpenTuiConfig, key: keyof OpenTuiConfig["footerSe
127
127
  }
128
128
 
129
129
  function cycleIconMode(config: OpenTuiConfig): OpenTuiConfig {
130
- const order: IconMode[] = ["auto", "nerd", "ascii"];
130
+ const order: IconMode[] = ["auto", "nerd", "unicode", "ascii"];
131
131
  const currentIdx = order.indexOf(config.icons.mode);
132
132
  const next = order[(currentIdx + 1) % order.length]!;
133
133
  return { ...config, icons: { mode: next } };
@@ -43,23 +43,28 @@ export function truncateBranch(branch: string, maxLen: number): string {
43
43
  }
44
44
 
45
45
  export function truncatePath(path: string, maxLen: number): string {
46
- if (path.length <= maxLen) return path;
46
+ // `maxLen` is a terminal-column budget, so every comparison here must use
47
+ // display width. Comparing String.length let wide (CJK) paths stay wider than
48
+ // the budget: for a basename of 4 characters but 8 columns, `truncatePath`
49
+ // returned it unchanged, so fitSegmentsByPriority's `while (totalW() > maxW)`
50
+ // loop never made progress and livelocked the renderer at 100% CPU.
51
+ if (visibleWidth(path) <= maxLen) return path;
47
52
  if (maxLen <= 3) return "...".slice(0, maxLen);
48
53
  const sepChar = path.includes("/") ? "/" : "\\";
49
54
  const parts = path.split(/[\\/]/);
50
- if (parts.length <= 2) return path.slice(0, maxLen - 3) + "...";
55
+ if (parts.length <= 2) return truncateToWidth(path, maxLen, "...");
51
56
  // Keep first segment (e.g. ~) and as many trailing segments as fit.
52
57
  const tail: string[] = [];
53
58
  let tailLen = 0;
54
59
  for (let i = parts.length - 1; i >= 1; i--) {
55
60
  const seg = parts[i]!;
56
- if (tailLen + seg.length + 4 > maxLen) break;
61
+ if (tailLen + visibleWidth(seg) + 4 > maxLen) break;
57
62
  tail.unshift(seg);
58
- tailLen += seg.length + 1;
63
+ tailLen += visibleWidth(seg) + 1;
59
64
  }
60
65
  const head = parts[0]!;
61
66
  const result = `${head}${sepChar}...${sepChar}${tail.join(sepChar)}`;
62
- return result.length > maxLen ? result.slice(0, maxLen - 3) + "..." : result;
67
+ return visibleWidth(result) > maxLen ? truncateToWidth(result, maxLen, "...") : result;
63
68
  }
64
69
 
65
70
  export function finiteOrZero(value: unknown): number {
@@ -158,7 +163,25 @@ export function fitSegmentsByPriority(
158
163
  if (totalW() <= maxW) break;
159
164
  }
160
165
  }
166
+ // A segment whose `truncate` cannot actually shrink would otherwise spin here
167
+ // forever and block the render timer. Require each pass to make progress, and
168
+ // drop the worst segment when it does not, so layout always terminates.
169
+ let lastTotal = Infinity;
161
170
  while (totalW() > maxW) {
171
+ const nowTotal = totalW();
172
+ if (nowTotal >= lastTotal) {
173
+ let worst = -1;
174
+ for (let i = 0; i < items.length; i++) {
175
+ if (items[i].text !== "" && (worst === -1 || items[i].priority < items[worst].priority)) {
176
+ worst = i;
177
+ }
178
+ }
179
+ if (worst === -1) break;
180
+ items[worst].text = "";
181
+ items[worst].w = 0;
182
+ continue;
183
+ }
184
+ lastTotal = nowTotal;
162
185
  let target = -1;
163
186
  for (let i = 0; i < items.length; i++) {
164
187
  if (items[i].text !== "" && (target === -1 || items[i].priority < items[target].priority)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
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": {
@@ -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.81",
39
- "@earendil-works/pi-ai": ">=0.81",
40
- "@earendil-works/pi-tui": ">=0.81"
38
+ "@earendil-works/pi-coding-agent": ">=0.85",
39
+ "@earendil-works/pi-ai": ">=0.85",
40
+ "@earendil-works/pi-tui": ">=0.85"
41
41
  },
42
42
  "peerDependenciesMeta": {
43
43
  "@earendil-works/pi-coding-agent": {