pi-open-tui 0.3.7 → 0.3.8

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
@@ -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
@@ -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
  }
@@ -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 {
@@ -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
  }
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.8",
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": {