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 +4 -0
- package/README.zh-CN.md +4 -0
- package/extensions/open-tui/config.ts +5 -0
- package/extensions/open-tui/editor.ts +71 -3
- package/extensions/open-tui/footer.ts +211 -111
- package/extensions/open-tui/index.ts +12 -2
- package/extensions/open-tui/settings-command.ts +4 -0
- package/package.json +1 -1
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
|
-
|
|
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(
|
|
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
|
-
):
|
|
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
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|