pi-open-tui 0.3.7 → 0.3.9

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,7 +19,7 @@ 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
24
  - A [Nerd Font](https://www.nerdfonts.com/font-downloads) for the full icon set (optional; ASCII icons are built in)
25
25
 
@@ -56,6 +56,7 @@ Run `/open-tui` to open the settings dialog. It provides **General**, **Appearan
56
56
  ```json
57
57
  {
58
58
  "enabled": true,
59
+ "inlineFooter": false,
59
60
  "settingsLanguage": "en",
60
61
  "cursorStyle": "block",
61
62
  "fullscreen": {
@@ -98,6 +99,7 @@ Key options:
98
99
  | Option | Values | Notes |
99
100
  | --- | --- | --- |
100
101
  | `settingsLanguage` | `en`, `zh` | Changes the `/open-tui` interface language |
102
+ | `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 |
101
103
  | `cursorStyle` | `block`, `bar`, `underline` | `bar` and `underline` require terminal cursor-shape support |
102
104
  | `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`) |
103
105
  | `icons.mode` | `auto`, `nerd`, `ascii` | Controls footer and telemetry icons |
@@ -108,6 +110,8 @@ Key options:
108
110
 
109
111
  `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.
110
112
 
113
+ 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.
114
+
111
115
  Fullscreen wheel speed uses an isolated compatibility shim for Pi 0.84.2's runtime field because Pi does not yet expose a public setter. On Pi versions without a compatible field, the setting is ignored and Pi's default scrolling remains active.
112
116
 
113
117
  ## Turn telemetry
package/README.zh-CN.md CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  ## 环境要求
21
21
 
22
- - Pi 0.80 或更高版本
22
+ - Pi 0.85 或更高版本
23
23
  - 支持 UTF-8 和彩色输出的终端
24
24
  - 使用完整图标集时需要 [Nerd Font](https://www.nerdfonts.com/font-downloads)(可选;内置 ASCII 图标)
25
25
 
@@ -56,6 +56,7 @@ pi -e npm:pi-open-tui
56
56
  ```json
57
57
  {
58
58
  "enabled": true,
59
+ "inlineFooter": false,
59
60
  "settingsLanguage": "zh",
60
61
  "cursorStyle": "block",
61
62
  "fullscreen": {
@@ -98,6 +99,7 @@ pi -e npm:pi-open-tui
98
99
  | 选项 | 可选值 | 说明 |
99
100
  | --- | --- | --- |
100
101
  | `settingsLanguage` | `en`、`zh` | 切换 `/open-tui` 设置界面的语言 |
102
+ | `inlineFooter` | `true`、`false` | 将两条主要 Footer 信息行移入编辑器上下边框以节省垂直空间,默认关闭;扩展状态行仍显示在编辑器外 |
101
103
  | `cursorStyle` | `block`、`bar`、`underline` | `bar` 和 `underline` 需要终端支持光标形状转义序列 |
102
104
  | `fullscreen.wheelScrollLines` | `1`-`10` | 全屏模式下滚轮每格滚动的行数,默认值为 `4`;`/open-tui` 中在该项上按 Enter 后直接输入数字(超出范围会自动钳制到 `1`-`10`) |
103
105
  | `icons.mode` | `auto`、`nerd`、`ascii` | 控制底栏和遥测通知使用的图标 |
@@ -108,6 +110,8 @@ pi -e npm:pi-open-tui
108
110
 
109
111
  `sessionName` 仅在会话有名称时显示;`hostname` 会显示主机名的短名称(主机名的第一个标签,例如从 `mba.example.com` 显示为 `mba`),并使用服务器图标;`gitCommit` 会在 detached HEAD 状态下显示短哈希和标签;关闭 `extensionStatuses` 会隐藏整行扩展状态,其中也包括 MCP 状态。
110
112
 
113
+ 开启 `inlineFooter` 后,两条常规 Footer 信息行会移入编辑器边框,从而节省垂直空间。顶部边框左侧显示 Git 分支,右侧信息组以当前目录开头;开启 `sessionName` 时,会话标题也会显示在左侧。Header 和扩展状态行保持独立显示;终端较窄时优先截断低优先级 Footer 数据,保留右侧统计信息和边框角。
114
+
111
115
  全屏滚轮速度通过隔离的兼容层写入 Pi 0.84.2 的运行时字段,因为 Pi 尚未提供公开 setter。若后续 Pi 版本不再包含兼容字段,该设置会被忽略并继续使用 Pi 的默认滚动行为。
112
116
 
113
117
  ## 单轮遥测
@@ -48,6 +48,7 @@ export interface FullscreenConfig {
48
48
 
49
49
  export interface OpenTuiConfig {
50
50
  enabled: boolean;
51
+ inlineFooter: boolean;
51
52
  settingsLanguage: SettingsLanguage;
52
53
  cursorStyle: CursorStyle;
53
54
  fullscreen: FullscreenConfig;
@@ -61,6 +62,7 @@ export interface OpenTuiConfig {
61
62
 
62
63
  export const DEFAULT_CONFIG: OpenTuiConfig = {
63
64
  enabled: true,
65
+ inlineFooter: false,
64
66
  settingsLanguage: "en",
65
67
  cursorStyle: "block",
66
68
  fullscreen: {
@@ -151,6 +153,9 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
151
153
  const raw = readFileSync(path, "utf8");
152
154
  const parsed: unknown = JSON.parse(raw);
153
155
  const config = deepMerge(DEFAULT_CONFIG, parsed);
156
+ if (typeof config.inlineFooter !== "boolean") {
157
+ config.inlineFooter = DEFAULT_CONFIG.inlineFooter;
158
+ }
154
159
  if (config.settingsLanguage !== "en" && config.settingsLanguage !== "zh") {
155
160
  config.settingsLanguage = DEFAULT_CONFIG.settingsLanguage;
156
161
  }
@@ -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,12 +24,24 @@ 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;
30
32
  renderSpinnerInBorder(width: number): string;
31
33
  }
32
34
 
35
+ export interface InlineFooterLine {
36
+ left: string;
37
+ right: string;
38
+ }
39
+
40
+ export interface InlineFooterRenderer {
41
+ enabled(): boolean;
42
+ render(width: number): { top: InlineFooterLine; bottom: InlineFooterLine } | undefined;
43
+ }
44
+
33
45
  interface HiddenThinkingLabelComponent {
34
46
  children: unknown[];
35
47
  setHiddenThinkingLabel(label: string): void;
@@ -132,11 +144,59 @@ function roundedBorder(
132
144
  return paint(`${corners[0]}${"─".repeat(Math.max(0, width - 2))}${corners[1]}`);
133
145
  }
134
146
 
147
+ function trimTrailingSpaces(text: string): string {
148
+ const ansiSuffix = text.match(/(?:\x1b\[[0-?]*[ -/]*[@-~])+$/)?.[0] ?? "";
149
+ const content = ansiSuffix ? text.slice(0, -ansiSuffix.length) : text;
150
+ return `${content.replace(/ +$/, "")}${ansiSuffix}`;
151
+ }
152
+
153
+ function inlineBorder(
154
+ width: number,
155
+ kind: "top" | "bottom",
156
+ paint: (s: string) => string,
157
+ renderLine: (width: number) => InlineFooterLine | undefined,
158
+ sourceLine?: string,
159
+ indicator?: WorkingStatusIndicator,
160
+ ): string {
161
+ if (width < 2) return paint(truncateToWidth(kind === "top" ? "╭╮" : "╰╯", width, ""));
162
+
163
+ const corners = kind === "top" ? (["╭", "╮"] as const) : (["╰", "╯"] as const);
164
+ const contentWidth = width - 2;
165
+ const plain = sourceLine ? stripAnsi(sourceLine) : "";
166
+ const scrollMatch = plain.match(/([↑↓]\s+\d+\s+more)/);
167
+ const right = scrollMatch ? paint(` ${scrollMatch[1]} `) : "";
168
+ let left = paint("─");
169
+
170
+ if (kind === "top" && indicator) {
171
+ const statusBudget = Math.max(1, contentWidth - visibleWidth(right) - 7);
172
+ let status = indicator.renderInBorder(statusBudget);
173
+ if (visibleWidth(status) > statusBudget) status = indicator.renderSpinnerInBorder(statusBudget);
174
+ const leftBudget = Math.max(0, contentWidth - visibleWidth(right) - 1);
175
+ status = truncateToWidth(status, Math.max(0, leftBudget - 4), "");
176
+ if (visibleWidth(status) > 0) left = `${paint("── ")}${status}${paint(" ")}`;
177
+ }
178
+
179
+ const lineBudget = Math.max(0, contentWidth - visibleWidth(left) - visibleWidth(right) - 6);
180
+ const line = renderLine(lineBudget);
181
+ if (!line) return roundedBorder(width, kind, paint, sourceLine, indicator);
182
+ const leftContent = trimTrailingSpaces(line.left);
183
+ const rightContent = trimTrailingSpaces(line.right);
184
+ if (visibleWidth(leftContent) === 0 && visibleWidth(rightContent) === 0) {
185
+ return roundedBorder(width, kind, paint, sourceLine, indicator);
186
+ }
187
+
188
+ const leftCell = leftContent ? ` ${leftContent} ` : "";
189
+ const rightCell = rightContent ? ` ${rightContent} ` : "";
190
+ const fill = Math.max(1, contentWidth - visibleWidth(left) - visibleWidth(leftCell) - visibleWidth(rightCell) - visibleWidth(right) - 1);
191
+ return `${paint(corners[0])}${left}${leftCell}${paint("─".repeat(fill))}${rightCell}${right}${paint("─")}${paint(corners[1])}`;
192
+ }
193
+
135
194
  export class OpenTuiEditor extends CustomEditor {
136
195
  readonly embedWorkingStatus = true;
137
196
  private readonly getRail: () => string;
138
197
  private readonly getBorder: (s: string) => string;
139
198
  private embeddedWorkingStatusIndicator: WorkingStatusIndicator | undefined;
199
+ private readonly inlineFooter: InlineFooterRenderer | undefined;
140
200
  private cursorStyle: CursorStyle;
141
201
  private previewHardwareCursor = false;
142
202
 
@@ -145,9 +205,11 @@ export class OpenTuiEditor extends CustomEditor {
145
205
  editorTheme: EditorTheme,
146
206
  keybindings: KeybindingsManager,
147
207
  cursorStyle: CursorStyle = "block",
208
+ inlineFooter?: InlineFooterRenderer,
148
209
  ) {
149
210
  super(tui, editorTheme, keybindings, { paddingX: 0 });
150
211
  this.cursorStyle = cursorStyle;
212
+ this.inlineFooter = inlineFooter;
151
213
  configureCursor(tui, cursorStyle);
152
214
  // ponytail: route the frame through this.borderColor so Pi can recolor it
153
215
  // via updateEditorBorderColor() — bash mode ("! " prefix → green) and
@@ -161,6 +223,16 @@ export class OpenTuiEditor extends CustomEditor {
161
223
  super.setPaddingX(0);
162
224
  }
163
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
+
164
236
  setWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): void {
165
237
  this.embeddedWorkingStatusIndicator = indicator;
166
238
  this.tui.requestRender();
@@ -202,17 +274,22 @@ export class OpenTuiEditor extends CustomEditor {
202
274
  }
203
275
 
204
276
  render(width: number): string[] {
205
- if (width < 4) return this.renderBase(width);
277
+ if (width < EDITOR_FRAME_HORIZONTAL_CHROME) return this.renderBase(width);
206
278
 
207
279
  const rail = this.getRail();
208
280
  const borderPaint = this.getBorder;
209
281
  // ponytail: 1-char rail + 1-char gap on each side = 4 chars of chrome.
210
- const innerWidth = Math.max(0, width - 4);
282
+ const innerWidth = Math.max(0, width - EDITOR_FRAME_HORIZONTAL_CHROME);
211
283
  const baseLines = this.renderBase(innerWidth);
212
284
  const bottomIdx = findBottomBorderIndex(baseLines);
213
285
 
214
286
  const result: string[] = [];
215
- result.push(roundedBorder(width, "top", borderPaint, baseLines[0], this.embeddedWorkingStatusIndicator));
287
+ const inlineFooter = this.inlineFooter?.enabled() === true;
288
+ const renderInlineLine = (kind: "top" | "bottom", budget: number): InlineFooterLine | undefined =>
289
+ this.inlineFooter?.render(budget)?.[kind];
290
+ result.push(inlineFooter
291
+ ? inlineBorder(width, "top", borderPaint, (budget) => renderInlineLine("top", budget), baseLines[0], this.embeddedWorkingStatusIndicator)
292
+ : roundedBorder(width, "top", borderPaint, baseLines[0], this.embeddedWorkingStatusIndicator));
216
293
 
217
294
  for (let i = 1; i < bottomIdx; i++) {
218
295
  const line = baseLines[i] ?? "";
@@ -223,7 +300,9 @@ export class OpenTuiEditor extends CustomEditor {
223
300
  }
224
301
  }
225
302
 
226
- result.push(roundedBorder(width, "bottom", borderPaint, baseLines[bottomIdx]));
303
+ result.push(inlineFooter
304
+ ? inlineBorder(width, "bottom", borderPaint, (budget) => renderInlineLine("bottom", budget), baseLines[bottomIdx])
305
+ : roundedBorder(width, "bottom", borderPaint, baseLines[bottomIdx]));
227
306
 
228
307
  for (let i = bottomIdx + 1; i < baseLines.length; i++) {
229
308
  result.push(baseLines[i]!);
@@ -238,6 +317,7 @@ export function installEditor(
238
317
  ctx: ExtensionContext,
239
318
  cursorStyle: CursorStyle = "block",
240
319
  wheelScrollLines = DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
320
+ inlineFooter?: InlineFooterRenderer,
241
321
  ) {
242
322
  let activeTui: TUI | undefined;
243
323
  let activeEditor: OpenTuiEditor | undefined;
@@ -255,7 +335,7 @@ export function installEditor(
255
335
  hiddenThinkingTarget = undefined;
256
336
  applyFullscreenWheelScrollLines(tui, currentWheelScrollLines);
257
337
  previousHardwareCursor = tui.getShowHardwareCursor();
258
- activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle);
338
+ activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle, inlineFooter);
259
339
  return activeEditor;
260
340
  });
261
341
  return {
@@ -202,141 +202,241 @@ export interface FooterHooks {
202
202
  scheduleGitRefresh: () => void;
203
203
  }
204
204
 
205
+ interface FooterDataLike {
206
+ getExtensionStatuses(): ReadonlyMap<string, string>;
207
+ }
208
+
209
+ export interface InlineFooterLine {
210
+ left: string;
211
+ right: string;
212
+ }
213
+
214
+ export interface InlineFooterLines {
215
+ top: InlineFooterLine;
216
+ bottom: InlineFooterLine;
217
+ }
218
+
219
+ /**
220
+ * Split the bottom border content the way `alignRight` splits the plain row:
221
+ * the right block (statistics) survives, the left one (model) shrinks to an
222
+ * ellipsis and only then disappears. Keeps the assembled border inside its
223
+ * budget so editor-side truncation never eats the right corner.
224
+ */
225
+ function fitInlineLine(left: string, right: string, width: number, theme: Theme): InlineFooterLine {
226
+ const rightWidth = visibleWidth(right);
227
+ if (rightWidth > width) {
228
+ return { left: "", right: truncateToWidth(right, width, theme.fg("dim", "...")) };
229
+ }
230
+ const availableForLeft = width - rightWidth - 1;
231
+ if (availableForLeft <= 0) return { left: "", right };
232
+ return { left: truncateToWidth(left, availableForLeft, theme.fg("dim", "...")), right };
233
+ }
234
+
235
+ export interface FooterHandle {
236
+ renderInline(width: number): InlineFooterLines | undefined;
237
+ cleanup(): void;
238
+ }
239
+
240
+ function fitInlineSegments(
241
+ parts: readonly PrioritizedSegment[],
242
+ width: number,
243
+ theme: Theme,
244
+ ): string {
245
+ const separator = theme.fg("dim", " · ");
246
+ const separatorReserve = Math.max(0, parts.length - 1) * Math.max(0, visibleWidth(separator) - 1);
247
+ return fitSegmentsByPriority(
248
+ parts,
249
+ Math.max(0, width - separatorReserve),
250
+ theme.fg("dim", "..."),
251
+ ).join(separator);
252
+ }
253
+
254
+ function renderFooterContent(
255
+ ctx: ExtensionContext,
256
+ getState: () => FooterState,
257
+ getConfig: () => OpenTuiConfig,
258
+ getModelMeta: () => ModelMeta,
259
+ theme: Theme,
260
+ footerData: FooterDataLike,
261
+ width: number,
262
+ ): { mainLines: [string, string]; inlineLines: InlineFooterLines; extensionLines: string[] } {
263
+ if (width <= 0) {
264
+ return {
265
+ mainLines: ["", ""],
266
+ inlineLines: {
267
+ top: { left: "", right: "" },
268
+ bottom: { left: "", right: "" },
269
+ },
270
+ extensionLines: [],
271
+ };
272
+ }
273
+
274
+ const state = getState();
275
+ const config = getConfig();
276
+ const glyphs = resolveGlyphs(config.icons.mode);
277
+ const segments = config.footerSegments;
278
+ const meta = getModelMeta();
279
+ const totals = getUsageTotals(ctx);
280
+
281
+ const leftParts: PrioritizedSegment[] = [];
282
+ const inlineTopLeftParts: PrioritizedSegment[] = [];
283
+ const inlineTopRightParts: PrioritizedSegment[] = [];
284
+ if (segments.cwd) {
285
+ const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
286
+ const cwd = formatCwd(ctx.sessionManager.getCwd());
287
+ const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
288
+ const accent = (text: string) => theme.fg("accent", text);
289
+ leftParts.push({
290
+ text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
291
+ compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
292
+ priority: 0,
293
+ truncate: (_text, maxWidth, ellipsis) => {
294
+ const pathWidth = maxWidth - visibleWidth(cwdPrefix);
295
+ if (pathWidth <= visibleWidth(ellipsis)) {
296
+ return truncateToWidth(`${cwdPrefix}${accent(basenamePath(cwd))}`, maxWidth, ellipsis);
297
+ }
298
+ return `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), pathWidth))}`;
299
+ },
300
+ });
301
+ inlineTopRightParts.push(leftParts.at(-1)!);
302
+ }
303
+ if (segments.hostname) {
304
+ const shortHost = shortHostname(osHostname());
305
+ if (shortHost) {
306
+ leftParts.push({
307
+ text: `${theme.fg("dim", glyphs.host)} ${theme.fg("accent", shortHost)}`,
308
+ priority: 1,
309
+ });
310
+ inlineTopRightParts.push(leftParts.at(-1)!);
311
+ }
312
+ }
313
+ const sessionName = ctx.sessionManager.getSessionName();
314
+ if (sessionName) {
315
+ const sessionPart: PrioritizedSegment = {
316
+ text: `${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
317
+ priority: 2,
318
+ };
319
+ if (segments.sessionName) {
320
+ leftParts.push(sessionPart);
321
+ inlineTopLeftParts.push(sessionPart);
322
+ }
323
+ }
324
+ const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
325
+ if (gitSeg) {
326
+ leftParts.push({ text: gitSeg, priority: 3 });
327
+ inlineTopLeftParts.push(leftParts.at(-1)!);
328
+ }
329
+ if (segments.runtime) {
330
+ const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
331
+ if (runtimeSeg) {
332
+ leftParts.push({ text: runtimeSeg, priority: 4 });
333
+ inlineTopRightParts.push(leftParts.at(-1)!);
334
+ }
335
+ }
336
+ const timerSeg = renderTimerSegment(theme, state, glyphs);
337
+ if (timerSeg) {
338
+ leftParts.push({ text: timerSeg, priority: 1 });
339
+ inlineTopRightParts.push(leftParts.at(-1)!);
340
+ }
341
+
342
+ // The context bar competes with the left segments for the same row:
343
+ // full bar first, then the compact icon+pct form, then dropped.
344
+ let contextText = "";
345
+ let contextCompact: string | undefined;
346
+ if (segments.context) {
347
+ contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
348
+ const compact = renderContextCompact(theme, ctx, glyphs);
349
+ if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
350
+ contextCompact = compact;
351
+ }
352
+ }
353
+ const allParts: PrioritizedSegment[] = [...leftParts];
354
+ if (contextText) {
355
+ // ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
356
+ allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
357
+ // Context stays at the far right; cwd is the first item in this group.
358
+ inlineTopRightParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
359
+ }
360
+
361
+ const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
362
+ const fittedContext = contextText ? fitted.pop() ?? "" : "";
363
+ const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
364
+
365
+ const inlineLeftBudget = Math.floor(width * 0.45);
366
+ const inlineTopLeft = fitInlineSegments(inlineTopLeftParts, inlineLeftBudget, theme);
367
+ const inlineRightBudget = Math.max(0, width - visibleWidth(inlineTopLeft) - (inlineTopLeft ? 1 : 0));
368
+ const inlineTop: InlineFooterLine = {
369
+ left: inlineTopLeft,
370
+ right: fitInlineSegments(inlineTopRightParts, inlineRightBudget, theme),
371
+ };
372
+
373
+ const modelParts: string[] = [];
374
+ modelParts.push(theme.fg("mdLink", glyphs.model));
375
+ if (meta.provider && meta.provider !== "Unknown") {
376
+ modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
377
+ }
378
+ modelParts.push(theme.fg("text", meta.model));
379
+ if (meta.effort && meta.effort !== "off") {
380
+ modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
381
+ }
382
+ const modelBlock = modelParts.join(theme.fg("dim", " · "));
383
+ const statsBlock = renderStatsBlock(theme, totals, glyphs, segments);
384
+ const inlineBottom = fitInlineLine(modelBlock, statsBlock, width, theme);
385
+ const line2 = alignRight(inlineBottom.left, inlineBottom.right, width, theme);
386
+ const mainLines: [string, string] = [line1, line2]
387
+ .map((line) => truncateToWidth(line, width, theme.fg("dim", "..."))) as [string, string];
388
+ const extensionLines = segments.extensionStatuses
389
+ ? renderExtensionStatusLines(theme, footerData.getExtensionStatuses(), glyphs, width)
390
+ : [];
391
+ return { mainLines, inlineLines: { top: inlineTop, bottom: inlineBottom }, extensionLines };
392
+ }
393
+
205
394
  export function installFooter(
206
395
  ctx: ExtensionContext,
207
396
  getState: () => FooterState,
208
397
  getConfig: () => OpenTuiConfig,
209
398
  getModelMeta: () => ModelMeta,
210
399
  hooks: FooterHooks,
211
- ): () => void {
400
+ ): FooterHandle {
401
+ let renderInline: (width: number) => InlineFooterLines | undefined = () => undefined;
402
+
212
403
  ctx.ui.setFooter((tui, theme, footerData) => {
213
404
  hooks.setRequestRender(() => tui.requestRender());
214
405
  const unsubBranch = footerData.onBranchChange(() => {
215
406
  hooks.scheduleGitRefresh();
216
407
  tui.requestRender();
217
408
  });
409
+ const getContent = (width: number) => renderFooterContent(
410
+ ctx,
411
+ getState,
412
+ getConfig,
413
+ getModelMeta,
414
+ theme,
415
+ footerData,
416
+ width,
417
+ );
418
+
419
+ renderInline = (width) => getContent(width).inlineLines;
218
420
 
219
421
  return {
220
422
  dispose() {
221
423
  unsubBranch();
222
424
  hooks.setRequestRender(undefined);
425
+ renderInline = () => undefined;
223
426
  },
224
427
  invalidate() {},
225
428
  render(width: number): string[] {
226
429
  if (width <= 0) return [""];
227
- const state = getState();
228
- const config = getConfig();
229
- const glyphs = resolveGlyphs(config.icons.mode);
230
- const segments = config.footerSegments;
231
- const meta = getModelMeta();
232
-
233
- const totals = getUsageTotals(ctx);
234
-
235
- const leftParts: PrioritizedSegment[] = [];
236
- if (segments.cwd) {
237
- const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
238
- const cwd = formatCwd(ctx.sessionManager.getCwd());
239
- const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
240
- const accent = (text: string) => theme.fg("accent", text);
241
- leftParts.push({
242
- text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
243
- compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
244
- priority: 0,
245
- truncate: (_text, maxWidth, ellipsis) => {
246
- const pathWidth = maxWidth - visibleWidth(cwdPrefix);
247
- if (pathWidth <= visibleWidth(ellipsis)) {
248
- return truncateToWidth(`${cwdPrefix}${accent(basenamePath(cwd))}`, maxWidth, ellipsis);
249
- }
250
- return `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), pathWidth))}`;
251
- },
252
- });
253
- }
254
- if (segments.hostname) {
255
- const shortHost = shortHostname(osHostname());
256
- if (shortHost) {
257
- leftParts.push({
258
- text: `${theme.fg("dim", glyphs.host)} ${theme.fg("accent", shortHost)}`,
259
- priority: 1,
260
- });
261
- }
262
- }
263
- if (segments.sessionName) {
264
- const sessionName = ctx.sessionManager.getSessionName();
265
- if (sessionName) {
266
- leftParts.push({
267
- text: `${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
268
- priority: 2,
269
- });
270
- }
271
- }
272
- const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
273
- if (gitSeg) leftParts.push({ text: gitSeg, priority: 3 });
274
- if (segments.runtime) {
275
- const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
276
- if (runtimeSeg) leftParts.push({ text: runtimeSeg, priority: 4 });
277
- }
278
- const timerSeg = renderTimerSegment(theme, state, glyphs);
279
- if (timerSeg) leftParts.push({ text: timerSeg, priority: 1 });
280
-
281
- // The context bar competes with the left segments for the same row:
282
- // full bar first, then the compact icon+pct form, then dropped.
283
- let contextText = "";
284
- let contextCompact: string | undefined;
285
- if (segments.context) {
286
- contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
287
- const compact = renderContextCompact(theme, ctx, glyphs);
288
- if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
289
- contextCompact = compact;
290
- }
291
- }
292
- const allParts: PrioritizedSegment[] = [...leftParts];
293
- if (contextText) {
294
- // ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
295
- allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
296
- }
297
-
298
- const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
299
- const fittedContext = contextText ? fitted.pop() ?? "" : "";
300
- const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
301
-
302
- const modelParts: string[] = [];
303
- modelParts.push(theme.fg("mdLink", glyphs.model));
304
- if (meta.provider && meta.provider !== "Unknown") {
305
- modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
306
- }
307
- modelParts.push(theme.fg("text", meta.model));
308
- if (meta.effort && meta.effort !== "off") {
309
- modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
310
- }
311
- const modelBlock = modelParts.join(theme.fg("dim", " · "));
312
-
313
- const statsBlock = renderStatsBlock(
314
- theme,
315
- totals,
316
- glyphs,
317
- segments,
318
- );
319
-
320
- const line2 = alignRight(modelBlock, statsBlock, width, theme);
321
-
322
- const mainLines = [line1, line2]
323
- .map((line) => truncateToWidth(line, width, theme.fg("dim", "...")));
324
- return segments.extensionStatuses
325
- ? [
326
- ...mainLines,
327
- ...renderExtensionStatusLines(
328
- theme,
329
- footerData.getExtensionStatuses(),
330
- glyphs,
331
- width,
332
- ),
333
- ]
334
- : mainLines;
430
+ const { mainLines, extensionLines } = getContent(width);
431
+ return getConfig().inlineFooter ? extensionLines : [...mainLines, ...extensionLines];
335
432
  },
336
433
  };
337
434
  });
338
435
 
339
- return () => {
340
- ctx.ui.setFooter(undefined);
436
+ return {
437
+ renderInline: (width) => renderInline(width),
438
+ cleanup() {
439
+ ctx.ui.setFooter(undefined);
440
+ },
341
441
  };
342
442
  }
@@ -131,7 +131,7 @@ export default function (pi: ExtensionAPI) {
131
131
  }
132
132
  if (!active) {
133
133
  cleanupHeader = installHeader(pi, ctx);
134
- cleanupFooter = installFooter(
134
+ const footer = installFooter(
135
135
  ctx,
136
136
  () => state,
137
137
  () => config,
@@ -145,7 +145,17 @@ export default function (pi: ExtensionAPI) {
145
145
  },
146
146
  },
147
147
  );
148
- editor = installEditor(pi, ctx, config.cursorStyle, config.fullscreen.wheelScrollLines);
148
+ cleanupFooter = footer.cleanup;
149
+ editor = installEditor(
150
+ pi,
151
+ ctx,
152
+ config.cursorStyle,
153
+ config.fullscreen.wheelScrollLines,
154
+ {
155
+ enabled: () => config.inlineFooter,
156
+ render: footer.renderInline,
157
+ },
158
+ );
149
159
  active = true;
150
160
  }
151
161
  };
@@ -33,6 +33,7 @@ const COPY = {
33
33
  hint: "Tab/Shift+Tab/←/→: tabs · ↑/↓: move · Enter/Space: change · Enter on wheel speed: type 1-10 · Esc/q: close",
34
34
  labels: {
35
35
  enabled: "Enabled",
36
+ inlineFooter: "Inline footer",
36
37
  thinkingPeek: "Thinking peek",
37
38
  language: "Language",
38
39
  wheelScrollLines: "Mouse wheel speed",
@@ -72,6 +73,7 @@ const COPY = {
72
73
  hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · 滚轮速度项 Enter 输入 1-10 · Esc/q:关闭",
73
74
  labels: {
74
75
  enabled: "启用",
76
+ inlineFooter: "内联底栏",
75
77
  thinkingPeek: "思考预览",
76
78
  language: "语言",
77
79
  wheelScrollLines: "鼠标滚轮速度",
@@ -185,6 +187,7 @@ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
185
187
  currentValue: copy.values.wheelLines(config.fullscreen.wheelScrollLines),
186
188
  },
187
189
  { id: "thinkingPeek", label: copy.labels.thinkingPeek, currentValue: formatThinkingPeekLines(config.thinkingPeek.lines, copy) },
190
+ { id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) },
188
191
  ];
189
192
  }
190
193
 
@@ -245,6 +248,7 @@ function handleSettingChange(
245
248
  ): OpenTuiConfig {
246
249
  if (tab === "features") {
247
250
  if (itemId === "enabled") return toggleEnabled(config);
251
+ if (itemId === "inlineFooter") return { ...config, inlineFooter: !config.inlineFooter };
248
252
  if (itemId === "settingsLanguage") return toggleLanguage(config);
249
253
  if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
250
254
  }
@@ -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.7",
3
+ "version": "0.3.9",
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": {