pi-tui-footer 1.2.0 → 1.2.2

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
@@ -4,6 +4,12 @@ pi 插件:TUI 美化套件 — 动画 Pi logo 头部、Starship 风格底部
4
4
 
5
5
  ## 安装
6
6
 
7
+ ```bash
8
+ pi install npm:pi-tui-footer
9
+ ```
10
+
11
+ 或通过 Git:
12
+
7
13
  ```bash
8
14
  pi install git:github.com/onenora/pi-tui-footer
9
15
  ```
@@ -11,7 +17,7 @@ pi install git:github.com/onenora/pi-tui-footer
11
17
  固定版本:
12
18
 
13
19
  ```bash
14
- pi install git:github.com/onenora/pi-tui-footer@v1.2.0
20
+ pi install git:github.com/onenora/pi-tui-footer@v1.2.2
15
21
  ```
16
22
 
17
23
  更新:
@@ -49,6 +49,7 @@ export interface FullscreenConfig {
49
49
  export interface OpenTuiConfig {
50
50
  enabled: boolean;
51
51
  roundedTools: boolean;
52
+ inlineFooter: boolean;
52
53
  settingsLanguage: SettingsLanguage;
53
54
  cursorStyle: CursorStyle;
54
55
  fullscreen: FullscreenConfig;
@@ -65,6 +66,7 @@ export type PiTuiConfig = OpenTuiConfig;
65
66
  export const DEFAULT_CONFIG: OpenTuiConfig = {
66
67
  enabled: true,
67
68
  roundedTools: true,
69
+ inlineFooter: false,
68
70
  settingsLanguage: "en",
69
71
  cursorStyle: "block",
70
72
  fullscreen: {
@@ -174,6 +176,9 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
174
176
  } else {
175
177
  config.thinkingPeek.lines = normalizeThinkingPeekLines(config.thinkingPeek.lines);
176
178
  }
179
+ if (typeof config.inlineFooter !== "boolean") {
180
+ config.inlineFooter = DEFAULT_CONFIG.inlineFooter;
181
+ }
177
182
  if (typeof config.roundedTools !== "boolean") {
178
183
  config.roundedTools = DEFAULT_CONFIG.roundedTools;
179
184
  }
@@ -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 {
@@ -204,141 +204,241 @@ export interface FooterHooks {
204
204
  scheduleGitRefresh: () => void;
205
205
  }
206
206
 
207
+ interface FooterDataLike {
208
+ getExtensionStatuses(): ReadonlyMap<string, string>;
209
+ }
210
+
211
+ export interface InlineFooterLine {
212
+ left: string;
213
+ right: string;
214
+ }
215
+
216
+ export interface InlineFooterLines {
217
+ top: InlineFooterLine;
218
+ bottom: InlineFooterLine;
219
+ }
220
+
221
+ /**
222
+ * Split the bottom border content the way `alignRight` splits the plain row:
223
+ * the right block (statistics) survives, the left one (model) shrinks to an
224
+ * ellipsis and only then disappears. Keeps the assembled border inside its
225
+ * budget so editor-side truncation never eats the right corner.
226
+ */
227
+ function fitInlineLine(left: string, right: string, width: number, theme: Theme): InlineFooterLine {
228
+ const rightWidth = visibleWidth(right);
229
+ if (rightWidth > width) {
230
+ return { left: "", right: truncateToWidth(right, width, theme.fg("dim", "...")) };
231
+ }
232
+ const availableForLeft = width - rightWidth - 1;
233
+ if (availableForLeft <= 0) return { left: "", right };
234
+ return { left: truncateToWidth(left, availableForLeft, theme.fg("dim", "...")), right };
235
+ }
236
+
237
+ export interface FooterHandle {
238
+ renderInline(width: number): InlineFooterLines | undefined;
239
+ cleanup(): void;
240
+ }
241
+
242
+ function fitInlineSegments(
243
+ parts: readonly PrioritizedSegment[],
244
+ width: number,
245
+ theme: Theme,
246
+ ): string {
247
+ const separator = theme.fg("dim", " · ");
248
+ const separatorReserve = Math.max(0, parts.length - 1) * Math.max(0, visibleWidth(separator) - 1);
249
+ return fitSegmentsByPriority(
250
+ parts,
251
+ Math.max(0, width - separatorReserve),
252
+ theme.fg("dim", "..."),
253
+ ).join(separator);
254
+ }
255
+
256
+ function renderFooterContent(
257
+ ctx: ExtensionContext,
258
+ getState: () => FooterState,
259
+ getConfig: () => OpenTuiConfig,
260
+ getModelMeta: () => ModelMeta,
261
+ theme: Theme,
262
+ footerData: FooterDataLike,
263
+ width: number,
264
+ ): { mainLines: [string, string]; inlineLines: InlineFooterLines; extensionLines: string[] } {
265
+ if (width <= 0) {
266
+ return {
267
+ mainLines: ["", ""],
268
+ inlineLines: {
269
+ top: { left: "", right: "" },
270
+ bottom: { left: "", right: "" },
271
+ },
272
+ extensionLines: [],
273
+ };
274
+ }
275
+
276
+ const state = getState();
277
+ const config = getConfig();
278
+ const glyphs = resolveGlyphs(config.icons.mode);
279
+ const segments = config.footerSegments;
280
+ const meta = getModelMeta();
281
+ const totals = getUsageTotals(ctx);
282
+
283
+ const leftParts: PrioritizedSegment[] = [];
284
+ const inlineTopLeftParts: PrioritizedSegment[] = [];
285
+ const inlineTopRightParts: PrioritizedSegment[] = [];
286
+ if (segments.cwd) {
287
+ const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
288
+ const cwd = formatCwd(ctx.sessionManager.getCwd());
289
+ const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
290
+ const accent = (text: string) => theme.fg("accent", text);
291
+ leftParts.push({
292
+ text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
293
+ compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
294
+ priority: 0,
295
+ truncate: (_text, maxWidth, ellipsis) => {
296
+ const pathWidth = maxWidth - visibleWidth(cwdPrefix);
297
+ if (pathWidth <= visibleWidth(ellipsis)) {
298
+ return truncateToWidth(`${cwdPrefix}${accent(basenamePath(cwd))}`, maxWidth, ellipsis);
299
+ }
300
+ return `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), pathWidth))}`;
301
+ },
302
+ });
303
+ inlineTopRightParts.push(leftParts.at(-1)!);
304
+ }
305
+ if (segments.hostname) {
306
+ const shortHost = shortHostname(osHostname());
307
+ if (shortHost) {
308
+ leftParts.push({
309
+ text: `${theme.fg("dim", glyphs.host)} ${theme.fg("accent", shortHost)}`,
310
+ priority: 1,
311
+ });
312
+ inlineTopRightParts.push(leftParts.at(-1)!);
313
+ }
314
+ }
315
+ const sessionName = ctx.sessionManager.getSessionName();
316
+ if (sessionName) {
317
+ const sessionPart: PrioritizedSegment = {
318
+ text: `${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
319
+ priority: 2,
320
+ };
321
+ if (segments.sessionName) {
322
+ leftParts.push(sessionPart);
323
+ inlineTopLeftParts.push(sessionPart);
324
+ }
325
+ }
326
+ const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
327
+ if (gitSeg) {
328
+ leftParts.push({ text: gitSeg, priority: 3 });
329
+ inlineTopLeftParts.push(leftParts.at(-1)!);
330
+ }
331
+ if (segments.runtime) {
332
+ const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
333
+ if (runtimeSeg) {
334
+ leftParts.push({ text: runtimeSeg, priority: 4 });
335
+ inlineTopRightParts.push(leftParts.at(-1)!);
336
+ }
337
+ }
338
+ const timerSeg = renderTimerSegment(theme, state, glyphs);
339
+ if (timerSeg) {
340
+ leftParts.push({ text: timerSeg, priority: 1 });
341
+ inlineTopRightParts.push(leftParts.at(-1)!);
342
+ }
343
+
344
+ // The context bar competes with the left segments for the same row:
345
+ // full bar first, then the compact icon+pct form, then dropped.
346
+ let contextText = "";
347
+ let contextCompact: string | undefined;
348
+ if (segments.context) {
349
+ contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
350
+ const compact = renderContextCompact(theme, ctx, glyphs);
351
+ if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
352
+ contextCompact = compact;
353
+ }
354
+ }
355
+ const allParts: PrioritizedSegment[] = [...leftParts];
356
+ if (contextText) {
357
+ // ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
358
+ allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
359
+ // Context stays at the far right; cwd is the first item in this group.
360
+ inlineTopRightParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
361
+ }
362
+
363
+ const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
364
+ const fittedContext = contextText ? fitted.pop() ?? "" : "";
365
+ const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
366
+
367
+ const inlineLeftBudget = Math.floor(width * 0.45);
368
+ const inlineTopLeft = fitInlineSegments(inlineTopLeftParts, inlineLeftBudget, theme);
369
+ const inlineRightBudget = Math.max(0, width - visibleWidth(inlineTopLeft) - (inlineTopLeft ? 1 : 0));
370
+ const inlineTop: InlineFooterLine = {
371
+ left: inlineTopLeft,
372
+ right: fitInlineSegments(inlineTopRightParts, inlineRightBudget, theme),
373
+ };
374
+
375
+ const modelParts: string[] = [];
376
+ modelParts.push(theme.fg("mdLink", glyphs.model));
377
+ if (meta.provider && meta.provider !== "Unknown") {
378
+ modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
379
+ }
380
+ modelParts.push(theme.fg("text", meta.model));
381
+ if (meta.effort && meta.effort !== "off") {
382
+ modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
383
+ }
384
+ const modelBlock = modelParts.join(theme.fg("dim", " · "));
385
+ const statsBlock = renderStatsBlock(theme, totals, glyphs, segments);
386
+ const inlineBottom = fitInlineLine(modelBlock, statsBlock, width, theme);
387
+ const line2 = alignRight(inlineBottom.left, inlineBottom.right, width, theme);
388
+ const mainLines: [string, string] = [line1, line2]
389
+ .map((line) => truncateToWidth(line, width, theme.fg("dim", "..."))) as [string, string];
390
+ const extensionLines = segments.extensionStatuses
391
+ ? renderExtensionStatusLines(theme, footerData.getExtensionStatuses(), glyphs, width)
392
+ : [];
393
+ return { mainLines, inlineLines: { top: inlineTop, bottom: inlineBottom }, extensionLines };
394
+ }
395
+
207
396
  export function installFooter(
208
397
  ctx: ExtensionContext,
209
398
  getState: () => FooterState,
210
399
  getConfig: () => OpenTuiConfig,
211
400
  getModelMeta: () => ModelMeta,
212
401
  hooks: FooterHooks,
213
- ): () => void {
402
+ ): FooterHandle {
403
+ let renderInline: (width: number) => InlineFooterLines | undefined = () => undefined;
404
+
214
405
  ctx.ui.setFooter((tui, theme, footerData) => {
215
406
  hooks.setRequestRender(() => tui.requestRender());
216
407
  const unsubBranch = footerData.onBranchChange(() => {
217
408
  hooks.scheduleGitRefresh();
218
409
  tui.requestRender();
219
410
  });
411
+ const getContent = (width: number) => renderFooterContent(
412
+ ctx,
413
+ getState,
414
+ getConfig,
415
+ getModelMeta,
416
+ theme,
417
+ footerData,
418
+ width,
419
+ );
420
+
421
+ renderInline = (width) => getContent(width).inlineLines;
220
422
 
221
423
  return {
222
424
  dispose() {
223
425
  unsubBranch();
224
426
  hooks.setRequestRender(undefined);
427
+ renderInline = () => undefined;
225
428
  },
226
429
  invalidate() {},
227
430
  render(width: number): string[] {
228
431
  if (width <= 0) return [""];
229
- const state = getState();
230
- const config = getConfig();
231
- const glyphs = resolveGlyphs(config.icons.mode);
232
- const segments = config.footerSegments;
233
- const meta = getModelMeta();
234
-
235
- const totals = getUsageTotals(ctx);
236
-
237
- const leftParts: PrioritizedSegment[] = [];
238
- if (segments.cwd) {
239
- const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
240
- const cwd = formatCwd(ctx.sessionManager.getCwd());
241
- const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
242
- const accent = (text: string) => theme.fg("accent", text);
243
- leftParts.push({
244
- text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
245
- compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
246
- priority: 0,
247
- truncate: (_text, maxWidth, ellipsis) => {
248
- const pathWidth = maxWidth - visibleWidth(cwdPrefix);
249
- if (pathWidth <= visibleWidth(ellipsis)) {
250
- return truncateToWidth(`${cwdPrefix}${accent(basenamePath(cwd))}`, maxWidth, ellipsis);
251
- }
252
- return `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), pathWidth))}`;
253
- },
254
- });
255
- }
256
- if (segments.hostname) {
257
- const shortHost = shortHostname(osHostname());
258
- if (shortHost) {
259
- leftParts.push({
260
- text: `${theme.fg("dim", glyphs.host)} ${theme.fg("accent", shortHost)}`,
261
- priority: 1,
262
- });
263
- }
264
- }
265
- if (segments.sessionName) {
266
- const sessionName = ctx.sessionManager.getSessionName();
267
- if (sessionName) {
268
- leftParts.push({
269
- text: `${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
270
- priority: 2,
271
- });
272
- }
273
- }
274
- const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
275
- if (gitSeg) leftParts.push({ text: gitSeg, priority: 3 });
276
- if (segments.runtime) {
277
- const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
278
- if (runtimeSeg) leftParts.push({ text: runtimeSeg, priority: 4 });
279
- }
280
- const timerSeg = renderTimerSegment(theme, state, glyphs);
281
- if (timerSeg) leftParts.push({ text: timerSeg, priority: 1 });
282
-
283
- // The context bar competes with the left segments for the same row:
284
- // full bar first, then the compact icon+pct form, then dropped.
285
- let contextText = "";
286
- let contextCompact: string | undefined;
287
- if (segments.context) {
288
- contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
289
- const compact = renderContextCompact(theme, ctx, glyphs);
290
- if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
291
- contextCompact = compact;
292
- }
293
- }
294
- const allParts: PrioritizedSegment[] = [...leftParts];
295
- if (contextText) {
296
- // ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
297
- allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
298
- }
299
-
300
- const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
301
- const fittedContext = contextText ? fitted.pop() ?? "" : "";
302
- const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
303
-
304
- const modelParts: string[] = [];
305
- modelParts.push(theme.fg("mdLink", glyphs.model));
306
- if (meta.provider && meta.provider !== "Unknown") {
307
- modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
308
- }
309
- modelParts.push(theme.fg("text", meta.model));
310
- if (meta.effort && meta.effort !== "off") {
311
- modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
312
- }
313
- const modelBlock = modelParts.join(theme.fg("dim", " · "));
314
-
315
- const statsBlock = renderStatsBlock(
316
- theme,
317
- totals,
318
- glyphs,
319
- segments,
320
- );
321
-
322
- const line2 = alignRight(modelBlock, statsBlock, width, theme);
323
-
324
- const mainLines = [line1, line2]
325
- .map((line) => truncateToWidth(line, width, theme.fg("dim", "...")));
326
- return segments.extensionStatuses
327
- ? [
328
- ...mainLines,
329
- ...renderExtensionStatusLines(
330
- theme,
331
- footerData.getExtensionStatuses(),
332
- glyphs,
333
- width,
334
- ),
335
- ]
336
- : mainLines;
432
+ const { mainLines, extensionLines } = getContent(width);
433
+ return getConfig().inlineFooter ? extensionLines : [...mainLines, ...extensionLines];
337
434
  },
338
435
  };
339
436
  });
340
437
 
341
- return () => {
342
- ctx.ui.setFooter(undefined);
438
+ return {
439
+ renderInline: (width) => renderInline(width),
440
+ cleanup() {
441
+ ctx.ui.setFooter(undefined);
442
+ },
343
443
  };
344
444
  }
@@ -171,7 +171,7 @@ export class OpenTuiHeader implements Component {
171
171
  this.ctx = ctx;
172
172
  const pool = collectPiCommandNames(pi.getCommands());
173
173
  this.tipCommands = pickSlashCommandTips(pool, {
174
- fixed: ["pi-tui"],
174
+ fixed: ["open-tui"],
175
175
  count: 3,
176
176
  });
177
177
  }
@@ -4,7 +4,7 @@
4
4
  * thinking peek preview, rounded tool frames, and turn telemetry.
5
5
  *
6
6
  * Integrated from:
7
- * - pi-open-tui (v0.3.7, MIT, by OldSun, https://github.com/OldSuns/pi-open-tui)
7
+ * - pi-open-tui (v0.3.9, MIT, by OldSun, https://github.com/OldSuns/pi-open-tui)
8
8
  * - pi-rounded-tools (v0.1.3, MIT, by OrionPax, https://github.com/orionpax1997/pi-rounded-tools)
9
9
  *
10
10
  * Security audit notes:
@@ -17,7 +17,7 @@
17
17
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
18
  import { type OpenTuiConfig, DEFAULT_CONFIG, ensureConfigExists, loadConfig, saveConfig } from "./config.ts";
19
19
  import { installEditor } from "./editor.ts";
20
- import { installFooter } from "./footer.ts";
20
+ import { installFooter, type FooterHandle } from "./footer.ts";
21
21
  import { installHeader } from "./header.ts";
22
22
  import { emptyGitStatus, readGitStatus } from "./git.ts";
23
23
  import { readRuntimeInfo } from "./runtime.ts";
@@ -71,7 +71,7 @@ export default function (pi: ExtensionAPI) {
71
71
  let requestFooterRender: (() => void) | undefined;
72
72
  let workingTimer: ReturnType<typeof setInterval> | undefined;
73
73
  let cleanupHeader: (() => void) | undefined;
74
- let cleanupFooter: (() => void) | undefined;
74
+ let cleanupFooter: FooterHandle | undefined;
75
75
  let editor: ReturnType<typeof installEditor> | undefined;
76
76
  let pendingUiChange: PendingUiChange | undefined;
77
77
 
@@ -150,7 +150,7 @@ export default function (pi: ExtensionAPI) {
150
150
  }
151
151
  if (!active) {
152
152
  cleanupHeader = installHeader(pi, ctx);
153
- cleanupFooter = installFooter(
153
+ const footer = installFooter(
154
154
  ctx,
155
155
  () => state,
156
156
  () => config,
@@ -164,7 +164,17 @@ export default function (pi: ExtensionAPI) {
164
164
  },
165
165
  },
166
166
  );
167
- editor = installEditor(pi, ctx, config.cursorStyle, config.fullscreen.wheelScrollLines);
167
+ cleanupFooter = footer;
168
+ editor = installEditor(
169
+ pi,
170
+ ctx,
171
+ config.cursorStyle,
172
+ config.fullscreen.wheelScrollLines,
173
+ {
174
+ enabled: () => config.inlineFooter,
175
+ render: footer.renderInline,
176
+ },
177
+ );
168
178
  active = true;
169
179
  }
170
180
  };
@@ -173,7 +183,7 @@ export default function (pi: ExtensionAPI) {
173
183
  if (!isTuiContext(ctx)) return;
174
184
  if (active) {
175
185
  cleanupHeader?.();
176
- cleanupFooter?.();
186
+ cleanupFooter?.cleanup();
177
187
  editor?.cleanup();
178
188
  cleanupHeader = undefined;
179
189
  cleanupFooter = undefined;
@@ -4,7 +4,7 @@
4
4
  * Merged 2026-08 from the standalone local extension rounded-tools.ts (a
5
5
  * localized copy of npm:pi-rounded-tools@0.1.3, MIT, by OrionPax,
6
6
  * https://github.com/orionpax1997/pi-rounded-tools), integrated into pi-tui
7
- * (based on https://github.com/OldSuns/pi-open-tui v0.3.7, MIT, by
7
+ * (based on https://github.com/OldSuns/pi-open-tui v0.3.9, MIT, by
8
8
  * OldSun) and gated behind `roundedTools` in pi-tui config
9
9
  * (~/.pi/agent/pi-tui.json, default on).
10
10
  *
@@ -34,6 +34,7 @@ const COPY = {
34
34
  labels: {
35
35
  enabled: "Enabled",
36
36
  roundedTools: "Rounded tool frames",
37
+ inlineFooter: "Inline footer",
37
38
  thinkingPeek: "Thinking peek",
38
39
  language: "Language",
39
40
  wheelScrollLines: "Mouse wheel speed",
@@ -74,6 +75,7 @@ const COPY = {
74
75
  labels: {
75
76
  enabled: "启用",
76
77
  roundedTools: "圆角工具边框",
78
+ inlineFooter: "内联底栏",
77
79
  thinkingPeek: "思考预览",
78
80
  language: "语言",
79
81
  wheelScrollLines: "鼠标滚轮速度",
@@ -181,6 +183,7 @@ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
181
183
  return [
182
184
  { id: "enabled", label: copy.labels.enabled, currentValue: flag(config.enabled) },
183
185
  { id: "roundedTools", label: copy.labels.roundedTools, currentValue: flag(config.roundedTools) },
186
+ { id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) },
184
187
  { id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
185
188
  {
186
189
  id: "wheelScrollLines",
@@ -249,6 +252,7 @@ function handleSettingChange(
249
252
  if (tab === "features") {
250
253
  if (itemId === "enabled") return toggleEnabled(config);
251
254
  if (itemId === "roundedTools") return { ...config, roundedTools: !config.roundedTools };
255
+ if (itemId === "inlineFooter") return { ...config, inlineFooter: !config.inlineFooter };
252
256
  if (itemId === "settingsLanguage") return toggleLanguage(config);
253
257
  if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
254
258
  }
@@ -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-tui-footer",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "pi extension: TUI footer/header with git status, telemetry, rounded tools, editor and settings enhancements",
5
5
  "type": "module",
6
6
  "keywords": [