pi-open-tui 0.3.6 → 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
@@ -41,11 +41,11 @@ pi -e npm:pi-open-tui
41
41
 
42
42
  Download any patched font from the official [Nerd Fonts downloads page](https://www.nerdfonts.com/font-downloads) or [latest GitHub release](https://github.com/ryanoasis/nerd-fonts/releases/latest). Install it, select that font in your terminal profile, and restart the terminal.
43
43
 
44
- The default `auto` mode detects the terminal environment, not the installed font file. If icons appear as boxes or incorrect symbols, open `/open-tui` and choose one of these modes under **Appearance**:
44
+ The default `auto` mode checks the terminal environment, not the installed font file. It uses Nerd Font icons in interactive UTF-8 TTYs, including terminals running through a runner or subshell. If icons appear as boxes or incorrect symbols, open `/open-tui` and choose one of these modes under **Appearance**:
45
45
 
46
46
  - `nerd`: force Nerd Font icons after configuring a Nerd Font in the terminal
47
47
  - `ascii`: use plain-text icons with no patched font required
48
- - `auto`: use Nerd Font icons in recognized terminals and ASCII elsewhere
48
+ - `auto`: use Nerd Font icons in interactive UTF-8 TTYs; use ASCII for non-interactive output, `TERM=dumb`, or an explicitly non-UTF-8 locale
49
49
 
50
50
  If the font is installed but `auto` still selects ASCII, choose `nerd` explicitly. In VS Code, Windows Terminal, and similar apps, configure the font in the terminal profile rather than only installing it in the operating system.
51
51
 
@@ -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": {
@@ -66,6 +67,7 @@ Run `/open-tui` to open the settings dialog. It provides **General**, **Appearan
66
67
  },
67
68
  "footerSegments": {
68
69
  "cwd": true,
70
+ "hostname": false,
69
71
  "sessionName": false,
70
72
  "gitBranch": true,
71
73
  "gitStatus": true,
@@ -74,7 +76,8 @@ Run `/open-tui` to open the settings dialog. It provides **General**, **Appearan
74
76
  "context": true,
75
77
  "tokens": true,
76
78
  "cost": true,
77
- "extensionStatuses": true
79
+ "extensionStatuses": true,
80
+ "capitalizeProviderName": true
78
81
  },
79
82
  "telemetry": {
80
83
  "enabled": true,
@@ -96,14 +99,18 @@ Key options:
96
99
  | Option | Values | Notes |
97
100
  | --- | --- | --- |
98
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 |
99
103
  | `cursorStyle` | `block`, `bar`, `underline` | `bar` and `underline` require terminal cursor-shape support |
100
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`) |
101
105
  | `icons.mode` | `auto`, `nerd`, `ascii` | Controls footer and telemetry icons |
102
106
  | `footerSegments` | Boolean flags | Shows or hides individual footer data |
107
+ | `footerSegments.capitalizeProviderName` | Boolean | Capitalizes the first character of the provider name in the footer; set it to `false` to keep the provider's original casing |
103
108
  | `telemetry` | Boolean flags | Enables telemetry and its individual measurements |
104
109
  | `thinkingPeek.lines` | `0`, `1`, `2` | Off, one-line, or two-line hidden thinking preview |
105
110
 
106
- `sessionName` appears only when the session has a name. `gitCommit` shows the short hash and tag in detached HEAD state. Disabling `extensionStatuses` hides the entire extension status line, including MCP status.
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.
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.
107
114
 
108
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.
109
116
 
@@ -125,7 +132,7 @@ When Pi's **Hide thinking** setting is enabled, pi-open-tui shows a compact tick
125
132
 
126
133
  - while the model is reasoning, the current thinking tail streams by with a spinner (`~ think ⠋ …`);
127
134
  - when the answer starts, it settles on a check mark (`~ think ✓`);
128
- - in 2-line mode, the previous and latest thinking lines share the same text indentation; if the latest line overflows, both rows follow its newest tail as tokens arrive instead of retaining the previous line;
135
+ - in 2-line mode, the previous and latest thinking lines share the same text indentation; if the latest line overflows, both rows follow its newest tail as tokens arrive instead of retaining the previous line;
129
136
  - after the task settles, the native `Thinking...` label is restored.
130
137
 
131
138
  ```text
package/README.zh-CN.md CHANGED
@@ -41,11 +41,11 @@ pi -e npm:pi-open-tui
41
41
 
42
42
  可从 [Nerd Fonts 官方下载页](https://www.nerdfonts.com/font-downloads)或 [GitHub 最新版本](https://github.com/ryanoasis/nerd-fonts/releases/latest)下载任意已修补字体。安装后,请在终端配置中选择该字体,并重启终端。
43
43
 
44
- 默认的 `auto` 模式检测的是终端环境,无法确认终端当前实际使用的字体。如果图标显示为方框、乱码或错误符号,请打开 `/open-tui`,在**外观**页选择合适的模式:
44
+ 默认的 `auto` 模式检查终端环境,而不是已安装的字体文件。在支持 UTF-8 的交互式 TTY 中,它会使用 Nerd Font 图标,即使 Pi 运行在 runner 或子 shell 中。如果图标显示为方框、乱码或错误符号,请打开 `/open-tui`,在**外观**页选择合适的模式:
45
45
 
46
46
  - `nerd`:终端已配置 Nerd Font 时,强制使用 Nerd Font 图标
47
47
  - `ascii`:使用纯文本图标,无需安装修补字体
48
- - `auto`:在已识别的终端中使用 Nerd Font 图标,其他环境回退到 ASCII
48
+ - `auto`:在支持 UTF-8 的交互式 TTY 中使用 Nerd Font 图标;非交互式输出、`TERM=dumb` 或明确配置为非 UTF-8 的 locale 使用 ASCII
49
49
 
50
50
  如果已经安装字体,但 `auto` 仍选择 ASCII,请手动切换为 `nerd`。使用 VS Code、Windows Terminal 等应用时,只在操作系统中安装字体还不够,还需要在对应的终端配置中选中该字体。
51
51
 
@@ -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": {
@@ -66,6 +67,7 @@ pi -e npm:pi-open-tui
66
67
  },
67
68
  "footerSegments": {
68
69
  "cwd": true,
70
+ "hostname": false,
69
71
  "sessionName": false,
70
72
  "gitBranch": true,
71
73
  "gitStatus": true,
@@ -74,7 +76,8 @@ pi -e npm:pi-open-tui
74
76
  "context": true,
75
77
  "tokens": true,
76
78
  "cost": true,
77
- "extensionStatuses": true
79
+ "extensionStatuses": true,
80
+ "capitalizeProviderName": true
78
81
  },
79
82
  "telemetry": {
80
83
  "enabled": true,
@@ -96,14 +99,18 @@ pi -e npm:pi-open-tui
96
99
  | 选项 | 可选值 | 说明 |
97
100
  | --- | --- | --- |
98
101
  | `settingsLanguage` | `en`、`zh` | 切换 `/open-tui` 设置界面的语言 |
102
+ | `inlineFooter` | `true`、`false` | 将两条主要 Footer 信息行移入编辑器上下边框以节省垂直空间,默认关闭;扩展状态行仍显示在编辑器外 |
99
103
  | `cursorStyle` | `block`、`bar`、`underline` | `bar` 和 `underline` 需要终端支持光标形状转义序列 |
100
104
  | `fullscreen.wheelScrollLines` | `1`-`10` | 全屏模式下滚轮每格滚动的行数,默认值为 `4`;`/open-tui` 中在该项上按 Enter 后直接输入数字(超出范围会自动钳制到 `1`-`10`) |
101
105
  | `icons.mode` | `auto`、`nerd`、`ascii` | 控制底栏和遥测通知使用的图标 |
102
106
  | `footerSegments` | 布尔开关 | 分别控制底栏中的各项数据 |
107
+ | `footerSegments.capitalizeProviderName` | 布尔开关 | 将底栏中提供商名称的首字母大写;设为 `false` 时保留原始大小写 |
103
108
  | `telemetry` | 布尔开关 | 控制遥测总开关和各项指标 |
104
109
  | `thinkingPeek.lines` | `0`、`1`、`2` | 关闭、单行或双行思考预览 |
105
110
 
106
- `sessionName` 仅在会话有名称时显示;`gitCommit` 会在 detached HEAD 状态下显示短哈希和标签;关闭 `extensionStatuses` 会隐藏整行扩展状态,其中也包括 MCP 状态。
111
+ `sessionName` 仅在会话有名称时显示;`hostname` 会显示主机名的短名称(主机名的第一个标签,例如从 `mba.example.com` 显示为 `mba`),并使用服务器图标;`gitCommit` 会在 detached HEAD 状态下显示短哈希和标签;关闭 `extensionStatuses` 会隐藏整行扩展状态,其中也包括 MCP 状态。
112
+
113
+ 开启 `inlineFooter` 后,两条常规 Footer 信息行会移入编辑器边框,从而节省垂直空间。顶部边框左侧显示 Git 分支,右侧信息组以当前目录开头;开启 `sessionName` 时,会话标题也会显示在左侧。Header 和扩展状态行保持独立显示;终端较窄时优先截断低优先级 Footer 数据,保留右侧统计信息和边框角。
107
114
 
108
115
  全屏滚轮速度通过隔离的兼容层写入 Pi 0.84.2 的运行时字段,因为 Pi 尚未提供公开 setter。若后续 Pi 版本不再包含兼容字段,该设置会被忽略并继续使用 Pi 的默认滚动行为。
109
116
 
@@ -125,7 +132,7 @@ TPS 的计算方式是:将本次运行中服务商报告的全部 Assistant
125
132
 
126
133
  - 模型推理时,思考内容的末尾片段会随 spinner 滚动(`~ think ⠋ …`);
127
134
  - 开始输出正文时定格为对勾(`~ think ✓`);
128
- - 双行模式中,上一条和最新一条思考内容使用相同的文字缩进;如果最新一行超出宽度,则两行随新 Token 持续显示它的最新末尾片段,不再保留上一条;
135
+ - 双行模式中,上一条和最新一条思考内容使用相同的文字缩进;如果最新一行超出宽度,则两行随新 Token 持续显示它的最新末尾片段,不再保留上一条;
129
136
  - 任务结束后恢复原生 `Thinking...` 标签。
130
137
 
131
138
  ```text
@@ -15,6 +15,7 @@ export type { IconMode } from "./icons.ts";
15
15
 
16
16
  export interface FooterSegments {
17
17
  cwd: boolean;
18
+ hostname: boolean;
18
19
  sessionName: boolean;
19
20
  gitBranch: boolean;
20
21
  gitStatus: boolean;
@@ -24,6 +25,7 @@ export interface FooterSegments {
24
25
  tokens: boolean;
25
26
  cost: boolean;
26
27
  extensionStatuses: boolean;
28
+ capitalizeProviderName: boolean;
27
29
  }
28
30
 
29
31
  export interface TelemetryConfig {
@@ -46,6 +48,7 @@ export interface FullscreenConfig {
46
48
 
47
49
  export interface OpenTuiConfig {
48
50
  enabled: boolean;
51
+ inlineFooter: boolean;
49
52
  settingsLanguage: SettingsLanguage;
50
53
  cursorStyle: CursorStyle;
51
54
  fullscreen: FullscreenConfig;
@@ -59,6 +62,7 @@ export interface OpenTuiConfig {
59
62
 
60
63
  export const DEFAULT_CONFIG: OpenTuiConfig = {
61
64
  enabled: true,
65
+ inlineFooter: false,
62
66
  settingsLanguage: "en",
63
67
  cursorStyle: "block",
64
68
  fullscreen: {
@@ -69,6 +73,7 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
69
73
  },
70
74
  footerSegments: {
71
75
  cwd: true,
76
+ hostname: false,
72
77
  sessionName: false,
73
78
  gitBranch: true,
74
79
  gitStatus: true,
@@ -78,6 +83,7 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
78
83
  tokens: true,
79
84
  cost: true,
80
85
  extensionStatuses: true,
86
+ capitalizeProviderName: true,
81
87
  },
82
88
  telemetry: {
83
89
  enabled: true,
@@ -147,6 +153,9 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
147
153
  const raw = readFileSync(path, "utf8");
148
154
  const parsed: unknown = JSON.parse(raw);
149
155
  const config = deepMerge(DEFAULT_CONFIG, parsed);
156
+ if (typeof config.inlineFooter !== "boolean") {
157
+ config.inlineFooter = DEFAULT_CONFIG.inlineFooter;
158
+ }
150
159
  if (config.settingsLanguage !== "en" && config.settingsLanguage !== "zh") {
151
160
  config.settingsLanguage = DEFAULT_CONFIG.settingsLanguage;
152
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
@@ -185,6 +245,11 @@ export class OpenTuiEditor extends CustomEditor {
185
245
  const renderedLines = super.render(width);
186
246
  if (this.cursorStyle === "block") return renderedLines;
187
247
 
248
+ // Pi re-applies settings.showHardwareCursor after session_start (/reload,
249
+ // /new, session switches). Non-block styles have no software cursor left to
250
+ // fall back on, so re-assert the hardware cursor instead of losing it.
251
+ if (!this.tui.getShowHardwareCursor()) configureCursor(this.tui, this.cursorStyle);
252
+
188
253
  // A focused overlay suppresses the editor's cursor marker. Preserve its
189
254
  // position only for the live settings preview, then clear it on refocus.
190
255
  let cursorMarker = this.previewHardwareCursor && !this.focused ? CURSOR_MARKER : "";
@@ -207,7 +272,12 @@ export class OpenTuiEditor extends CustomEditor {
207
272
  const bottomIdx = findBottomBorderIndex(baseLines);
208
273
 
209
274
  const result: string[] = [];
210
- 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));
211
281
 
212
282
  for (let i = 1; i < bottomIdx; i++) {
213
283
  const line = baseLines[i] ?? "";
@@ -218,7 +288,9 @@ export class OpenTuiEditor extends CustomEditor {
218
288
  }
219
289
  }
220
290
 
221
- 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]));
222
294
 
223
295
  for (let i = bottomIdx + 1; i < baseLines.length; i++) {
224
296
  result.push(baseLines[i]!);
@@ -233,6 +305,7 @@ export function installEditor(
233
305
  ctx: ExtensionContext,
234
306
  cursorStyle: CursorStyle = "block",
235
307
  wheelScrollLines = DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
308
+ inlineFooter?: InlineFooterRenderer,
236
309
  ) {
237
310
  let activeTui: TUI | undefined;
238
311
  let activeEditor: OpenTuiEditor | undefined;
@@ -250,7 +323,7 @@ export function installEditor(
250
323
  hiddenThinkingTarget = undefined;
251
324
  applyFullscreenWheelScrollLines(tui, currentWheelScrollLines);
252
325
  previousHardwareCursor = tui.getShowHardwareCursor();
253
- activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle);
326
+ activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle, inlineFooter);
254
327
  return activeEditor;
255
328
  });
256
329
  return {
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import { hostname as osHostname } from "node:os";
2
3
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
4
  import type { OpenTuiConfig } from "./config.ts";
4
5
  import type { IconGlyphs } from "./icons.ts";
@@ -25,6 +26,10 @@ import {
25
26
  import type { FooterState, ModelMeta, UsageTotals } from "./state.ts";
26
27
  import { getUsageTotals } from "./state.ts";
27
28
 
29
+ export function shortHostname(hostname: string): string {
30
+ return hostname.split(".")[0] ?? "";
31
+ }
32
+
28
33
  function renderBar(theme: Theme, pct: number, barWidth: number, ascii: boolean): string {
29
34
  const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
30
35
  const empty = barWidth - filled;
@@ -197,132 +202,241 @@ export interface FooterHooks {
197
202
  scheduleGitRefresh: () => void;
198
203
  }
199
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
+
200
394
  export function installFooter(
201
395
  ctx: ExtensionContext,
202
396
  getState: () => FooterState,
203
397
  getConfig: () => OpenTuiConfig,
204
398
  getModelMeta: () => ModelMeta,
205
399
  hooks: FooterHooks,
206
- ): () => void {
400
+ ): FooterHandle {
401
+ let renderInline: (width: number) => InlineFooterLines | undefined = () => undefined;
402
+
207
403
  ctx.ui.setFooter((tui, theme, footerData) => {
208
404
  hooks.setRequestRender(() => tui.requestRender());
209
405
  const unsubBranch = footerData.onBranchChange(() => {
210
406
  hooks.scheduleGitRefresh();
211
407
  tui.requestRender();
212
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;
213
420
 
214
421
  return {
215
422
  dispose() {
216
423
  unsubBranch();
217
424
  hooks.setRequestRender(undefined);
425
+ renderInline = () => undefined;
218
426
  },
219
427
  invalidate() {},
220
428
  render(width: number): string[] {
221
429
  if (width <= 0) return [""];
222
- const state = getState();
223
- const config = getConfig();
224
- const glyphs = resolveGlyphs(config.icons.mode);
225
- const segments = config.footerSegments;
226
- const meta = getModelMeta();
227
-
228
- const totals = getUsageTotals(ctx);
229
-
230
- const leftParts: PrioritizedSegment[] = [];
231
- if (segments.cwd) {
232
- const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
233
- const cwd = formatCwd(ctx.sessionManager.getCwd());
234
- const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
235
- const accent = (text: string) => theme.fg("accent", text);
236
- leftParts.push({
237
- text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
238
- compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
239
- priority: 0,
240
- truncate: (_text, maxWidth, ellipsis) => {
241
- const pathWidth = maxWidth - visibleWidth(cwdPrefix);
242
- if (pathWidth <= visibleWidth(ellipsis)) {
243
- return truncateToWidth(`${cwdPrefix}${accent(basenamePath(cwd))}`, maxWidth, ellipsis);
244
- }
245
- return `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), pathWidth))}`;
246
- },
247
- });
248
- }
249
- if (segments.sessionName) {
250
- const sessionName = ctx.sessionManager.getSessionName();
251
- if (sessionName) {
252
- leftParts.push({
253
- text: `${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
254
- priority: 2,
255
- });
256
- }
257
- }
258
- const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
259
- if (gitSeg) leftParts.push({ text: gitSeg, priority: 3 });
260
- if (segments.runtime) {
261
- const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
262
- if (runtimeSeg) leftParts.push({ text: runtimeSeg, priority: 4 });
263
- }
264
- const timerSeg = renderTimerSegment(theme, state, glyphs);
265
- if (timerSeg) leftParts.push({ text: timerSeg, priority: 1 });
266
-
267
- // The context bar competes with the left segments for the same row:
268
- // full bar first, then the compact icon+pct form, then dropped.
269
- let contextText = "";
270
- let contextCompact: string | undefined;
271
- if (segments.context) {
272
- contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
273
- const compact = renderContextCompact(theme, ctx, glyphs);
274
- if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
275
- contextCompact = compact;
276
- }
277
- }
278
- const allParts: PrioritizedSegment[] = [...leftParts];
279
- if (contextText) {
280
- // ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
281
- allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
282
- }
283
-
284
- const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
285
- const fittedContext = contextText ? fitted.pop() ?? "" : "";
286
- const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
287
-
288
- const modelParts: string[] = [];
289
- modelParts.push(theme.fg("mdLink", glyphs.model));
290
- if (meta.provider && meta.provider !== "Unknown") {
291
- modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
292
- }
293
- modelParts.push(theme.fg("text", meta.model));
294
- if (meta.effort && meta.effort !== "off") {
295
- modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
296
- }
297
- const modelBlock = modelParts.join(theme.fg("dim", " · "));
298
-
299
- const statsBlock = renderStatsBlock(
300
- theme,
301
- totals,
302
- glyphs,
303
- segments,
304
- );
305
-
306
- const line2 = alignRight(modelBlock, statsBlock, width, theme);
307
-
308
- const mainLines = [line1, line2]
309
- .map((line) => truncateToWidth(line, width, theme.fg("dim", "...")));
310
- return segments.extensionStatuses
311
- ? [
312
- ...mainLines,
313
- ...renderExtensionStatusLines(
314
- theme,
315
- footerData.getExtensionStatuses(),
316
- glyphs,
317
- width,
318
- ),
319
- ]
320
- : mainLines;
430
+ const { mainLines, extensionLines } = getContent(width);
431
+ return getConfig().inlineFooter ? extensionLines : [...mainLines, ...extensionLines];
321
432
  },
322
433
  };
323
434
  });
324
435
 
325
- return () => {
326
- ctx.ui.setFooter(undefined);
436
+ return {
437
+ renderInline: (width) => renderInline(width),
438
+ cleanup() {
439
+ ctx.ui.setFooter(undefined);
440
+ },
327
441
  };
328
442
  }
@@ -2,6 +2,7 @@ export type IconMode = "auto" | "nerd" | "ascii";
2
2
 
3
3
  export interface IconGlyphs {
4
4
  cwd: string;
5
+ host: string;
5
6
  session: string;
6
7
  git: string;
7
8
  working: string;
@@ -31,6 +32,7 @@ export interface IconGlyphs {
31
32
 
32
33
  const NERD_GLYPHS: IconGlyphs = {
33
34
  cwd: "",
35
+ host: "",
34
36
  session: "",
35
37
  git: "",
36
38
  working: "",
@@ -64,6 +66,7 @@ const NERD_GLYPHS: IconGlyphs = {
64
66
  // avoid collisions with the git-status set {= S ! A ? r x ^ v}.
65
67
  const ASCII_GLYPHS: IconGlyphs = {
66
68
  cwd: "@",
69
+ host: "h",
67
70
  session: "s",
68
71
  git: "*",
69
72
  working: "o",
@@ -91,33 +94,13 @@ const ASCII_GLYPHS: IconGlyphs = {
91
94
  deleted: "x",
92
95
  };
93
96
 
94
- const NERD_FONT_TERMINALS = new Set([
95
- "iTerm.app",
96
- "Ghostty",
97
- "WezTerm",
98
- "kitty",
99
- "rio",
100
- "tabby",
101
- "WindowsTerminal",
102
- "vscode",
103
- ]);
104
-
105
97
  export function detectNerdFont(): boolean {
106
- const termProgram = process.env.TERM_PROGRAM;
107
- if (termProgram && NERD_FONT_TERMINALS.has(termProgram)) return true;
108
-
109
- const lcTerminal = process.env.LC_TERMINAL;
110
- if (lcTerminal && NERD_FONT_TERMINALS.has(lcTerminal)) return true;
111
-
112
- if (process.env.TERM === "xterm-kitty") return true;
113
-
114
- // Windows Terminal sets WT_SESSION (not TERM_PROGRAM)
115
- if (process.env.WT_SESSION) return true;
116
-
117
- // VS Code integrated terminal
118
- if (process.env.TERM_PROGRAM === "vscode") return true;
119
-
120
- return false;
98
+ // TTY and locale checks are the only reliable signals available here. The
99
+ // terminal emulator owns font selection, so auto mode is optimistic once
100
+ // output is an interactive UTF-8 TTY.
101
+ if (process.env.TERM === "dumb" || process.stdout.isTTY !== true) return false;
102
+ const locale = [process.env.LC_ALL, process.env.LC_CTYPE, process.env.LANG].find(Boolean);
103
+ return locale === undefined || /utf-?8/i.test(locale);
121
104
  }
122
105
 
123
106
  export function resolveIconMode(mode: IconMode): "nerd" | "ascii" {
@@ -23,10 +23,10 @@ import {
23
23
  type PeekState,
24
24
  } from "./peek.ts";
25
25
 
26
- type PendingUiChange = "install" | "uninstall";
27
- const HIDDEN_THINKING_HORIZONTAL_PADDING = 2;
28
- // Pi's fullscreen transcript can reserve one more column for its scrollbar.
29
- const HIDDEN_THINKING_SCROLLBAR_RESERVE = 1;
26
+ type PendingUiChange = "install" | "uninstall";
27
+ const HIDDEN_THINKING_HORIZONTAL_PADDING = 2;
28
+ // Pi's fullscreen transcript can reserve one more column for its scrollbar.
29
+ const HIDDEN_THINKING_SCROLLBAR_RESERVE = 1;
30
30
 
31
31
  export function getPendingUiChange(enabled: boolean, active: boolean): PendingUiChange | undefined {
32
32
  if (enabled === active) return undefined;
@@ -57,10 +57,10 @@ export default function (pi: ExtensionAPI) {
57
57
  let editor: ReturnType<typeof installEditor> | undefined;
58
58
  let pendingUiChange: PendingUiChange | undefined;
59
59
 
60
- // thinking-peek state (rendered by Pi inside its hidden-thinking block)
61
- const peek: PeekState = createPeekState();
62
- let peekFrame = 0;
63
- let peekSettleTimer: ReturnType<typeof setTimeout> | undefined;
60
+ // thinking-peek state (rendered by Pi inside its hidden-thinking block)
61
+ const peek: PeekState = createPeekState();
62
+ let peekFrame = 0;
63
+ let peekSettleTimer: ReturnType<typeof setTimeout> | undefined;
64
64
  let peekTaskEpoch = 0; // bumped at every agent task boundary
65
65
  let peekLabelActive = false;
66
66
 
@@ -78,47 +78,47 @@ export default function (pi: ExtensionAPI) {
78
78
  /** Pi decides whether this label is visible; our toggle only controls updates. */
79
79
  const isPeekEnabled = () => sessionLifecycle.isCurrent() && config.enabled && config.thinkingPeek.lines > 0 && active;
80
80
 
81
- /** Keep each native hidden-thinking label row within Pi's narrowest transcript width. */
82
- const hiddenThinkingLabelWidth = (): number => {
83
- if (!editor) throw new Error("Open TUI editor is not installed");
84
- return Math.max(
85
- 1,
86
- editor.getViewportWidth() - HIDDEN_THINKING_HORIZONTAL_PADDING - HIDDEN_THINKING_SCROLLBAR_RESERVE,
87
- );
88
- };
81
+ /** Keep each native hidden-thinking label row within Pi's narrowest transcript width. */
82
+ const hiddenThinkingLabelWidth = (): number => {
83
+ if (!editor) throw new Error("Open TUI editor is not installed");
84
+ return Math.max(
85
+ 1,
86
+ editor.getViewportWidth() - HIDDEN_THINKING_HORIZONTAL_PADDING - HIDDEN_THINKING_SCROLLBAR_RESERVE,
87
+ );
88
+ };
89
89
 
90
90
  const setPeekLabel = (ctx?: ExtensionContext): void => {
91
91
  if (!isPeekEnabled() || peek.phase === "idle") return;
92
92
  const target = ctx ?? lastCtx;
93
93
  if (!target || !isTuiContext(target)) return;
94
- if (!editor) throw new Error("Open TUI editor is not installed");
95
- editor.setLatestHiddenThinkingLabel(
96
- buildPeekLabel(
97
- peek,
98
- peekFrame,
99
- resolveGlyphs(config.icons.mode),
100
- hiddenThinkingLabelWidth(),
101
- config.thinkingPeek.lines,
102
- ),
103
- );
94
+ if (!editor) throw new Error("Open TUI editor is not installed");
95
+ editor.setLatestHiddenThinkingLabel(
96
+ buildPeekLabel(
97
+ peek,
98
+ peekFrame,
99
+ resolveGlyphs(config.icons.mode),
100
+ hiddenThinkingLabelWidth(),
101
+ config.thinkingPeek.lines,
102
+ ),
103
+ );
104
104
  peekLabelActive = true;
105
105
  };
106
106
 
107
107
  const clearPeekLabel = (ctx?: ExtensionContext): void => {
108
- if (!peekLabelActive) return;
109
- const target = ctx ?? lastCtx;
110
- if (!target || !isTuiContext(target)) return;
111
- // The global reset is safe here: every completed message returns to Pi's native label.
112
- target.ui.setHiddenThinkingLabel();
108
+ if (!peekLabelActive) return;
109
+ const target = ctx ?? lastCtx;
110
+ if (!target || !isTuiContext(target)) return;
111
+ // The global reset is safe here: every completed message returns to Pi's native label.
112
+ target.ui.setHiddenThinkingLabel();
113
113
  peekLabelActive = false;
114
114
  };
115
115
 
116
- /** Reset peek state to idle and cancel any scheduled cleanup. */
117
- const resetPeek = () => {
118
- peek.phase = "idle";
119
- peek.tail = "";
120
- editor?.resetHiddenThinkingLabelTarget();
121
- if (peekSettleTimer) {
116
+ /** Reset peek state to idle and cancel any scheduled cleanup. */
117
+ const resetPeek = () => {
118
+ peek.phase = "idle";
119
+ peek.tail = "";
120
+ editor?.resetHiddenThinkingLabelTarget();
121
+ if (peekSettleTimer) {
122
122
  clearTimeout(peekSettleTimer);
123
123
  peekSettleTimer = undefined;
124
124
  }
@@ -131,11 +131,11 @@ 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,
138
- () => getModelMeta(ctx, getThinkingLevel),
138
+ () => getModelMeta(ctx, getThinkingLevel, config.footerSegments.capitalizeProviderName),
139
139
  {
140
140
  setRequestRender: (fn) => {
141
141
  requestFooterRender = fn ?? undefined;
@@ -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
  };
@@ -292,22 +302,22 @@ export default function (pi: ExtensionAPI) {
292
302
  pi.on("message_update", (event, ctx) => {
293
303
  turnTelemetry.handle(event);
294
304
  if (!sessionLifecycle.isCurrent() || isAgentIdle(ctx)) return;
295
- if (!isPeekEnabled()) return;
296
- const message = event.message;
297
- if (!message || message.role !== "assistant") return;
298
- if (peek.phase === "done") return;
299
- const parts = collectPeekParts(message.content);
300
- const next = reducePeek(peek, parts);
305
+ if (!isPeekEnabled()) return;
306
+ const message = event.message;
307
+ if (!message || message.role !== "assistant") return;
308
+ if (peek.phase === "done") return;
309
+ const parts = collectPeekParts(message.content);
310
+ const next = reducePeek(peek, parts);
301
311
  const changed = next.phase !== peek.phase || next.tail !== peek.tail;
302
312
  peek.phase = next.phase;
303
- peek.tail = next.tail;
304
- if (!changed) return;
305
- if (peek.phase === "thinking") {
306
- peekFrame++;
307
- setPeekLabel(ctx);
308
- } else if (peek.phase === "done") {
309
- setPeekLabel(ctx);
310
- }
313
+ peek.tail = next.tail;
314
+ if (!changed) return;
315
+ if (peek.phase === "thinking") {
316
+ peekFrame++;
317
+ setPeekLabel(ctx);
318
+ } else if (peek.phase === "done") {
319
+ setPeekLabel(ctx);
320
+ }
311
321
  });
312
322
 
313
323
  pi.on("tool_execution_start", (event) => {
@@ -346,10 +356,10 @@ export default function (pi: ExtensionAPI) {
346
356
  pi.on("message_end", (event, ctx) => {
347
357
  turnTelemetry.handle(event);
348
358
  if (!sessionLifecycle.isCurrent()) return;
349
- if (event.message?.role === "assistant" && peek.phase === "thinking") {
350
- // Sub-message finished (answer text or a tool call): freeze the peek line.
351
- peek.phase = "done";
352
- setPeekLabel(ctx);
359
+ if (event.message?.role === "assistant" && peek.phase === "thinking") {
360
+ // Sub-message finished (answer text or a tool call): freeze the peek line.
361
+ peek.phase = "done";
362
+ setPeekLabel(ctx);
353
363
  }
354
364
  invalidateUsageCache();
355
365
  refreshInteractiveState(ctx);
@@ -33,12 +33,14 @@ 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",
39
40
  cursorStyle: "Cursor style",
40
41
  iconMode: "Icon mode",
41
42
  cwd: "CWD",
43
+ hostname: "Hostname",
42
44
  sessionName: "Session name",
43
45
  gitBranch: "Git branch",
44
46
  gitStatus: "Git status",
@@ -48,6 +50,7 @@ const COPY = {
48
50
  tokens: "Tokens",
49
51
  cost: "Cost",
50
52
  extensionStatuses: "Extension status line",
53
+ capitalizeProviderName: "Capitalize provider name",
51
54
  totalDuration: "Total duration",
52
55
  tokenCounts: "Token counts",
53
56
  stallDetails: "Stall details",
@@ -70,12 +73,14 @@ const COPY = {
70
73
  hint: "Tab/Shift+Tab/←/→:切页 · ↑/↓:移动 · Enter/Space:更改 · 滚轮速度项 Enter 输入 1-10 · Esc/q:关闭",
71
74
  labels: {
72
75
  enabled: "启用",
76
+ inlineFooter: "内联底栏",
73
77
  thinkingPeek: "思考预览",
74
78
  language: "语言",
75
79
  wheelScrollLines: "鼠标滚轮速度",
76
80
  cursorStyle: "光标样式",
77
81
  iconMode: "图标模式",
78
82
  cwd: "当前目录",
83
+ hostname: "主机名",
79
84
  sessionName: "会话名",
80
85
  gitBranch: "Git 分支",
81
86
  gitStatus: "Git 状态",
@@ -85,6 +90,7 @@ const COPY = {
85
90
  tokens: "Token",
86
91
  cost: "费用",
87
92
  extensionStatuses: "扩展状态行",
93
+ capitalizeProviderName: "提供商名称首字母大写",
88
94
  totalDuration: "总耗时",
89
95
  tokenCounts: "Token 数量",
90
96
  stallDetails: "停顿详情",
@@ -181,6 +187,7 @@ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
181
187
  currentValue: copy.values.wheelLines(config.fullscreen.wheelScrollLines),
182
188
  },
183
189
  { id: "thinkingPeek", label: copy.labels.thinkingPeek, currentValue: formatThinkingPeekLines(config.thinkingPeek.lines, copy) },
190
+ { id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) },
184
191
  ];
185
192
  }
186
193
 
@@ -196,6 +203,7 @@ function buildSegmentsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
196
203
  const flag = (value: boolean) => value ? copy.values.on : copy.values.off;
197
204
  return [
198
205
  { id: "cwd", label: copy.labels.cwd, currentValue: flag(segs.cwd) },
206
+ { id: "hostname", label: copy.labels.hostname, currentValue: flag(segs.hostname) },
199
207
  { id: "sessionName", label: copy.labels.sessionName, currentValue: flag(segs.sessionName) },
200
208
  { id: "gitBranch", label: copy.labels.gitBranch, currentValue: flag(segs.gitBranch) },
201
209
  { id: "gitStatus", label: copy.labels.gitStatus, currentValue: flag(segs.gitStatus) },
@@ -205,6 +213,7 @@ function buildSegmentsItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
205
213
  { id: "tokens", label: copy.labels.tokens, currentValue: flag(segs.tokens) },
206
214
  { id: "cost", label: copy.labels.cost, currentValue: flag(segs.cost) },
207
215
  { id: "extensionStatuses", label: copy.labels.extensionStatuses, currentValue: flag(segs.extensionStatuses) },
216
+ { id: "capitalizeProviderName", label: copy.labels.capitalizeProviderName, currentValue: flag(segs.capitalizeProviderName) },
208
217
  ];
209
218
  }
210
219
 
@@ -239,6 +248,7 @@ function handleSettingChange(
239
248
  ): OpenTuiConfig {
240
249
  if (tab === "features") {
241
250
  if (itemId === "enabled") return toggleEnabled(config);
251
+ if (itemId === "inlineFooter") return { ...config, inlineFooter: !config.inlineFooter };
242
252
  if (itemId === "settingsLanguage") return toggleLanguage(config);
243
253
  if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
244
254
  }
@@ -374,7 +384,7 @@ class SettingsUi implements SettingsUiHandle {
374
384
  description: editing || this.compact ? undefined : item.currentValue,
375
385
  } as SelectItem;
376
386
  });
377
- this.selectList = new SelectList(items, Math.min(items.length, 10), {
387
+ this.selectList = new SelectList(items, Math.min(items.length, 12), {
378
388
  selectedPrefix: (t) => this.theme.fg("accent", t),
379
389
  selectedText: (t) => this.theme.fg("accent", t),
380
390
  description: (t) => this.theme.fg("muted", t),
@@ -94,8 +94,9 @@ export interface ModelMeta {
94
94
  export function getModelMeta(
95
95
  ctx: ExtensionContext,
96
96
  getThinkingLevel: () => string,
97
+ capitalizeProviderName: boolean,
97
98
  ): ModelMeta {
98
- const provider = formatProviderLabel(ctx.model?.provider);
99
+ const provider = formatProviderLabel(ctx.model?.provider, capitalizeProviderName);
99
100
  const model = ctx.model?.name ?? ctx.model?.id ?? "no-model";
100
101
  const reasoning = ctx.model?.reasoning ?? false;
101
102
  const effort = reasoning ? getThinkingLevel() : undefined;
@@ -97,9 +97,9 @@ export function formatModelLabel(model: { provider?: string; id?: string } | nul
97
97
  return model.provider ? `${model.provider}/${model.id}` : model.id;
98
98
  }
99
99
 
100
- export function formatProviderLabel(provider: string | undefined): string {
100
+ export function formatProviderLabel(provider: string | undefined, capitalize: boolean): string {
101
101
  if (!provider) return "Unknown";
102
- return provider.charAt(0).toUpperCase() + provider.slice(1);
102
+ return capitalize ? provider.charAt(0).toUpperCase() + provider.slice(1) : provider;
103
103
  }
104
104
 
105
105
  export function alignRight(left: string, right: string, width: number, theme: Theme): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.3.6",
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": {