pi-tui-footer 1.2.0 → 1.2.1

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
  ```
@@ -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
  }
@@ -30,6 +30,16 @@ interface WorkingStatusIndicator {
30
30
  renderSpinnerInBorder(width: number): string;
31
31
  }
32
32
 
33
+ export interface InlineFooterLine {
34
+ left: string;
35
+ right: string;
36
+ }
37
+
38
+ export interface InlineFooterRenderer {
39
+ enabled(): boolean;
40
+ render(width: number): { top: InlineFooterLine; bottom: InlineFooterLine } | undefined;
41
+ }
42
+
33
43
  interface HiddenThinkingLabelComponent {
34
44
  children: unknown[];
35
45
  setHiddenThinkingLabel(label: string): void;
@@ -132,11 +142,59 @@ function roundedBorder(
132
142
  return paint(`${corners[0]}${"─".repeat(Math.max(0, width - 2))}${corners[1]}`);
133
143
  }
134
144
 
145
+ function trimTrailingSpaces(text: string): string {
146
+ const ansiSuffix = text.match(/(?:\x1b\[[0-?]*[ -/]*[@-~])+$/)?.[0] ?? "";
147
+ const content = ansiSuffix ? text.slice(0, -ansiSuffix.length) : text;
148
+ return `${content.replace(/ +$/, "")}${ansiSuffix}`;
149
+ }
150
+
151
+ function inlineBorder(
152
+ width: number,
153
+ kind: "top" | "bottom",
154
+ paint: (s: string) => string,
155
+ renderLine: (width: number) => InlineFooterLine | undefined,
156
+ sourceLine?: string,
157
+ indicator?: WorkingStatusIndicator,
158
+ ): string {
159
+ if (width < 2) return paint(truncateToWidth(kind === "top" ? "╭╮" : "╰╯", width, ""));
160
+
161
+ const corners = kind === "top" ? (["╭", "╮"] as const) : (["╰", "╯"] as const);
162
+ const contentWidth = width - 2;
163
+ const plain = sourceLine ? stripAnsi(sourceLine) : "";
164
+ const scrollMatch = plain.match(/([↑↓]\s+\d+\s+more)/);
165
+ const right = scrollMatch ? paint(` ${scrollMatch[1]} `) : "";
166
+ let left = paint("─");
167
+
168
+ if (kind === "top" && indicator) {
169
+ const statusBudget = Math.max(1, contentWidth - visibleWidth(right) - 7);
170
+ let status = indicator.renderInBorder(statusBudget);
171
+ if (visibleWidth(status) > statusBudget) status = indicator.renderSpinnerInBorder(statusBudget);
172
+ const leftBudget = Math.max(0, contentWidth - visibleWidth(right) - 1);
173
+ status = truncateToWidth(status, Math.max(0, leftBudget - 4), "");
174
+ if (visibleWidth(status) > 0) left = `${paint("── ")}${status}${paint(" ")}`;
175
+ }
176
+
177
+ const lineBudget = Math.max(0, contentWidth - visibleWidth(left) - visibleWidth(right) - 6);
178
+ const line = renderLine(lineBudget);
179
+ if (!line) return roundedBorder(width, kind, paint, sourceLine, indicator);
180
+ const leftContent = trimTrailingSpaces(line.left);
181
+ const rightContent = trimTrailingSpaces(line.right);
182
+ if (visibleWidth(leftContent) === 0 && visibleWidth(rightContent) === 0) {
183
+ return roundedBorder(width, kind, paint, sourceLine, indicator);
184
+ }
185
+
186
+ const leftCell = leftContent ? ` ${leftContent} ` : "";
187
+ const rightCell = rightContent ? ` ${rightContent} ` : "";
188
+ const fill = Math.max(1, contentWidth - visibleWidth(left) - visibleWidth(leftCell) - visibleWidth(rightCell) - visibleWidth(right) - 1);
189
+ return `${paint(corners[0])}${left}${leftCell}${paint("─".repeat(fill))}${rightCell}${right}${paint("─")}${paint(corners[1])}`;
190
+ }
191
+
135
192
  export class OpenTuiEditor extends CustomEditor {
136
193
  readonly embedWorkingStatus = true;
137
194
  private readonly getRail: () => string;
138
195
  private readonly getBorder: (s: string) => string;
139
196
  private embeddedWorkingStatusIndicator: WorkingStatusIndicator | undefined;
197
+ private readonly inlineFooter: InlineFooterRenderer | undefined;
140
198
  private cursorStyle: CursorStyle;
141
199
  private previewHardwareCursor = false;
142
200
 
@@ -145,9 +203,11 @@ export class OpenTuiEditor extends CustomEditor {
145
203
  editorTheme: EditorTheme,
146
204
  keybindings: KeybindingsManager,
147
205
  cursorStyle: CursorStyle = "block",
206
+ inlineFooter?: InlineFooterRenderer,
148
207
  ) {
149
208
  super(tui, editorTheme, keybindings, { paddingX: 0 });
150
209
  this.cursorStyle = cursorStyle;
210
+ this.inlineFooter = inlineFooter;
151
211
  configureCursor(tui, cursorStyle);
152
212
  // ponytail: route the frame through this.borderColor so Pi can recolor it
153
213
  // via updateEditorBorderColor() — bash mode ("! " prefix → green) and
@@ -212,7 +272,12 @@ export class OpenTuiEditor extends CustomEditor {
212
272
  const bottomIdx = findBottomBorderIndex(baseLines);
213
273
 
214
274
  const result: string[] = [];
215
- result.push(roundedBorder(width, "top", borderPaint, baseLines[0], this.embeddedWorkingStatusIndicator));
275
+ const inlineFooter = this.inlineFooter?.enabled() === true;
276
+ const renderInlineLine = (kind: "top" | "bottom", budget: number): InlineFooterLine | undefined =>
277
+ this.inlineFooter?.render(budget)?.[kind];
278
+ result.push(inlineFooter
279
+ ? inlineBorder(width, "top", borderPaint, (budget) => renderInlineLine("top", budget), baseLines[0], this.embeddedWorkingStatusIndicator)
280
+ : roundedBorder(width, "top", borderPaint, baseLines[0], this.embeddedWorkingStatusIndicator));
216
281
 
217
282
  for (let i = 1; i < bottomIdx; i++) {
218
283
  const line = baseLines[i] ?? "";
@@ -223,7 +288,9 @@ export class OpenTuiEditor extends CustomEditor {
223
288
  }
224
289
  }
225
290
 
226
- result.push(roundedBorder(width, "bottom", borderPaint, baseLines[bottomIdx]));
291
+ result.push(inlineFooter
292
+ ? inlineBorder(width, "bottom", borderPaint, (budget) => renderInlineLine("bottom", budget), baseLines[bottomIdx])
293
+ : roundedBorder(width, "bottom", borderPaint, baseLines[bottomIdx]));
227
294
 
228
295
  for (let i = bottomIdx + 1; i < baseLines.length; i++) {
229
296
  result.push(baseLines[i]!);
@@ -238,6 +305,7 @@ export function installEditor(
238
305
  ctx: ExtensionContext,
239
306
  cursorStyle: CursorStyle = "block",
240
307
  wheelScrollLines = DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
308
+ inlineFooter?: InlineFooterRenderer,
241
309
  ) {
242
310
  let activeTui: TUI | undefined;
243
311
  let activeEditor: OpenTuiEditor | undefined;
@@ -255,7 +323,7 @@ export function installEditor(
255
323
  hiddenThinkingTarget = undefined;
256
324
  applyFullscreenWheelScrollLines(tui, currentWheelScrollLines);
257
325
  previousHardwareCursor = tui.getShowHardwareCursor();
258
- activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle);
326
+ activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle, inlineFooter);
259
327
  return activeEditor;
260
328
  });
261
329
  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.8, 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.8, 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
  }
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.1",
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": [