pi-tui-footer 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/extensions/config.ts +5 -0
- package/extensions/editor.ts +86 -6
- package/extensions/footer.ts +211 -111
- package/extensions/header.ts +1 -1
- package/extensions/index.ts +16 -6
- package/extensions/rounded-tools.ts +1 -1
- package/extensions/settings-command.ts +4 -0
- package/extensions/utils.ts +28 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,12 @@ pi 插件:TUI 美化套件 — 动画 Pi logo 头部、Starship 风格底部
|
|
|
4
4
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:pi-tui-footer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
或通过 Git:
|
|
12
|
+
|
|
7
13
|
```bash
|
|
8
14
|
pi install git:github.com/onenora/pi-tui-footer
|
|
9
15
|
```
|
|
@@ -11,7 +17,7 @@ pi install git:github.com/onenora/pi-tui-footer
|
|
|
11
17
|
固定版本:
|
|
12
18
|
|
|
13
19
|
```bash
|
|
14
|
-
pi install git:github.com/onenora/pi-tui-footer@v1.2.
|
|
20
|
+
pi install git:github.com/onenora/pi-tui-footer@v1.2.2
|
|
15
21
|
```
|
|
16
22
|
|
|
17
23
|
更新:
|
package/extensions/config.ts
CHANGED
|
@@ -49,6 +49,7 @@ export interface FullscreenConfig {
|
|
|
49
49
|
export interface OpenTuiConfig {
|
|
50
50
|
enabled: boolean;
|
|
51
51
|
roundedTools: boolean;
|
|
52
|
+
inlineFooter: boolean;
|
|
52
53
|
settingsLanguage: SettingsLanguage;
|
|
53
54
|
cursorStyle: CursorStyle;
|
|
54
55
|
fullscreen: FullscreenConfig;
|
|
@@ -65,6 +66,7 @@ export type PiTuiConfig = OpenTuiConfig;
|
|
|
65
66
|
export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
66
67
|
enabled: true,
|
|
67
68
|
roundedTools: true,
|
|
69
|
+
inlineFooter: false,
|
|
68
70
|
settingsLanguage: "en",
|
|
69
71
|
cursorStyle: "block",
|
|
70
72
|
fullscreen: {
|
|
@@ -174,6 +176,9 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
|
|
|
174
176
|
} else {
|
|
175
177
|
config.thinkingPeek.lines = normalizeThinkingPeekLines(config.thinkingPeek.lines);
|
|
176
178
|
}
|
|
179
|
+
if (typeof config.inlineFooter !== "boolean") {
|
|
180
|
+
config.inlineFooter = DEFAULT_CONFIG.inlineFooter;
|
|
181
|
+
}
|
|
177
182
|
if (typeof config.roundedTools !== "boolean") {
|
|
178
183
|
config.roundedTools = DEFAULT_CONFIG.roundedTools;
|
|
179
184
|
}
|
package/extensions/editor.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
type ExtensionContext,
|
|
5
5
|
type KeybindingsManager,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
7
|
+
import type { EditorTheme, TuiMouseEvent, TuiMouseEventResult, TUI } from "@earendil-works/pi-tui";
|
|
8
8
|
import { CURSOR_MARKER, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
9
|
import type { CursorStyle } from "./config.ts";
|
|
10
10
|
import {
|
|
@@ -24,12 +24,24 @@ const CURSOR_STYLE_SEQUENCES: Partial<Record<CursorStyle, string>> = {
|
|
|
24
24
|
underline: "\x1b[4 q",
|
|
25
25
|
};
|
|
26
26
|
const DEFAULT_CURSOR_STYLE_SEQUENCE = "\x1b[0 q";
|
|
27
|
+
const EDITOR_FRAME_LEFT_INSET = 2;
|
|
28
|
+
const EDITOR_FRAME_HORIZONTAL_CHROME = EDITOR_FRAME_LEFT_INSET * 2;
|
|
27
29
|
|
|
28
30
|
interface WorkingStatusIndicator {
|
|
29
31
|
renderInBorder(width: number): string;
|
|
30
32
|
renderSpinnerInBorder(width: number): string;
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
export interface InlineFooterLine {
|
|
36
|
+
left: string;
|
|
37
|
+
right: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface InlineFooterRenderer {
|
|
41
|
+
enabled(): boolean;
|
|
42
|
+
render(width: number): { top: InlineFooterLine; bottom: InlineFooterLine } | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
interface HiddenThinkingLabelComponent {
|
|
34
46
|
children: unknown[];
|
|
35
47
|
setHiddenThinkingLabel(label: string): void;
|
|
@@ -132,11 +144,59 @@ function roundedBorder(
|
|
|
132
144
|
return paint(`${corners[0]}${"─".repeat(Math.max(0, width - 2))}${corners[1]}`);
|
|
133
145
|
}
|
|
134
146
|
|
|
147
|
+
function trimTrailingSpaces(text: string): string {
|
|
148
|
+
const ansiSuffix = text.match(/(?:\x1b\[[0-?]*[ -/]*[@-~])+$/)?.[0] ?? "";
|
|
149
|
+
const content = ansiSuffix ? text.slice(0, -ansiSuffix.length) : text;
|
|
150
|
+
return `${content.replace(/ +$/, "")}${ansiSuffix}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function inlineBorder(
|
|
154
|
+
width: number,
|
|
155
|
+
kind: "top" | "bottom",
|
|
156
|
+
paint: (s: string) => string,
|
|
157
|
+
renderLine: (width: number) => InlineFooterLine | undefined,
|
|
158
|
+
sourceLine?: string,
|
|
159
|
+
indicator?: WorkingStatusIndicator,
|
|
160
|
+
): string {
|
|
161
|
+
if (width < 2) return paint(truncateToWidth(kind === "top" ? "╭╮" : "╰╯", width, ""));
|
|
162
|
+
|
|
163
|
+
const corners = kind === "top" ? (["╭", "╮"] as const) : (["╰", "╯"] as const);
|
|
164
|
+
const contentWidth = width - 2;
|
|
165
|
+
const plain = sourceLine ? stripAnsi(sourceLine) : "";
|
|
166
|
+
const scrollMatch = plain.match(/([↑↓]\s+\d+\s+more)/);
|
|
167
|
+
const right = scrollMatch ? paint(` ${scrollMatch[1]} `) : "";
|
|
168
|
+
let left = paint("─");
|
|
169
|
+
|
|
170
|
+
if (kind === "top" && indicator) {
|
|
171
|
+
const statusBudget = Math.max(1, contentWidth - visibleWidth(right) - 7);
|
|
172
|
+
let status = indicator.renderInBorder(statusBudget);
|
|
173
|
+
if (visibleWidth(status) > statusBudget) status = indicator.renderSpinnerInBorder(statusBudget);
|
|
174
|
+
const leftBudget = Math.max(0, contentWidth - visibleWidth(right) - 1);
|
|
175
|
+
status = truncateToWidth(status, Math.max(0, leftBudget - 4), "");
|
|
176
|
+
if (visibleWidth(status) > 0) left = `${paint("── ")}${status}${paint(" ")}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const lineBudget = Math.max(0, contentWidth - visibleWidth(left) - visibleWidth(right) - 6);
|
|
180
|
+
const line = renderLine(lineBudget);
|
|
181
|
+
if (!line) return roundedBorder(width, kind, paint, sourceLine, indicator);
|
|
182
|
+
const leftContent = trimTrailingSpaces(line.left);
|
|
183
|
+
const rightContent = trimTrailingSpaces(line.right);
|
|
184
|
+
if (visibleWidth(leftContent) === 0 && visibleWidth(rightContent) === 0) {
|
|
185
|
+
return roundedBorder(width, kind, paint, sourceLine, indicator);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const leftCell = leftContent ? ` ${leftContent} ` : "";
|
|
189
|
+
const rightCell = rightContent ? ` ${rightContent} ` : "";
|
|
190
|
+
const fill = Math.max(1, contentWidth - visibleWidth(left) - visibleWidth(leftCell) - visibleWidth(rightCell) - visibleWidth(right) - 1);
|
|
191
|
+
return `${paint(corners[0])}${left}${leftCell}${paint("─".repeat(fill))}${rightCell}${right}${paint("─")}${paint(corners[1])}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
135
194
|
export class OpenTuiEditor extends CustomEditor {
|
|
136
195
|
readonly embedWorkingStatus = true;
|
|
137
196
|
private readonly getRail: () => string;
|
|
138
197
|
private readonly getBorder: (s: string) => string;
|
|
139
198
|
private embeddedWorkingStatusIndicator: WorkingStatusIndicator | undefined;
|
|
199
|
+
private readonly inlineFooter: InlineFooterRenderer | undefined;
|
|
140
200
|
private cursorStyle: CursorStyle;
|
|
141
201
|
private previewHardwareCursor = false;
|
|
142
202
|
|
|
@@ -145,9 +205,11 @@ export class OpenTuiEditor extends CustomEditor {
|
|
|
145
205
|
editorTheme: EditorTheme,
|
|
146
206
|
keybindings: KeybindingsManager,
|
|
147
207
|
cursorStyle: CursorStyle = "block",
|
|
208
|
+
inlineFooter?: InlineFooterRenderer,
|
|
148
209
|
) {
|
|
149
210
|
super(tui, editorTheme, keybindings, { paddingX: 0 });
|
|
150
211
|
this.cursorStyle = cursorStyle;
|
|
212
|
+
this.inlineFooter = inlineFooter;
|
|
151
213
|
configureCursor(tui, cursorStyle);
|
|
152
214
|
// ponytail: route the frame through this.borderColor so Pi can recolor it
|
|
153
215
|
// via updateEditorBorderColor() — bash mode ("! " prefix → green) and
|
|
@@ -161,6 +223,16 @@ export class OpenTuiEditor extends CustomEditor {
|
|
|
161
223
|
super.setPaddingX(0);
|
|
162
224
|
}
|
|
163
225
|
|
|
226
|
+
override handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
227
|
+
if (event.width < EDITOR_FRAME_HORIZONTAL_CHROME) return super.handleMouse(event);
|
|
228
|
+
|
|
229
|
+
return super.handleMouse({
|
|
230
|
+
...event,
|
|
231
|
+
x: event.x - EDITOR_FRAME_LEFT_INSET,
|
|
232
|
+
width: event.width - EDITOR_FRAME_HORIZONTAL_CHROME,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
164
236
|
setWorkingStatusIndicator(indicator: WorkingStatusIndicator | undefined): void {
|
|
165
237
|
this.embeddedWorkingStatusIndicator = indicator;
|
|
166
238
|
this.tui.requestRender();
|
|
@@ -202,17 +274,22 @@ export class OpenTuiEditor extends CustomEditor {
|
|
|
202
274
|
}
|
|
203
275
|
|
|
204
276
|
render(width: number): string[] {
|
|
205
|
-
if (width <
|
|
277
|
+
if (width < EDITOR_FRAME_HORIZONTAL_CHROME) return this.renderBase(width);
|
|
206
278
|
|
|
207
279
|
const rail = this.getRail();
|
|
208
280
|
const borderPaint = this.getBorder;
|
|
209
281
|
// ponytail: 1-char rail + 1-char gap on each side = 4 chars of chrome.
|
|
210
|
-
const innerWidth = Math.max(0, width -
|
|
282
|
+
const innerWidth = Math.max(0, width - EDITOR_FRAME_HORIZONTAL_CHROME);
|
|
211
283
|
const baseLines = this.renderBase(innerWidth);
|
|
212
284
|
const bottomIdx = findBottomBorderIndex(baseLines);
|
|
213
285
|
|
|
214
286
|
const result: string[] = [];
|
|
215
|
-
|
|
287
|
+
const inlineFooter = this.inlineFooter?.enabled() === true;
|
|
288
|
+
const renderInlineLine = (kind: "top" | "bottom", budget: number): InlineFooterLine | undefined =>
|
|
289
|
+
this.inlineFooter?.render(budget)?.[kind];
|
|
290
|
+
result.push(inlineFooter
|
|
291
|
+
? inlineBorder(width, "top", borderPaint, (budget) => renderInlineLine("top", budget), baseLines[0], this.embeddedWorkingStatusIndicator)
|
|
292
|
+
: roundedBorder(width, "top", borderPaint, baseLines[0], this.embeddedWorkingStatusIndicator));
|
|
216
293
|
|
|
217
294
|
for (let i = 1; i < bottomIdx; i++) {
|
|
218
295
|
const line = baseLines[i] ?? "";
|
|
@@ -223,7 +300,9 @@ export class OpenTuiEditor extends CustomEditor {
|
|
|
223
300
|
}
|
|
224
301
|
}
|
|
225
302
|
|
|
226
|
-
result.push(
|
|
303
|
+
result.push(inlineFooter
|
|
304
|
+
? inlineBorder(width, "bottom", borderPaint, (budget) => renderInlineLine("bottom", budget), baseLines[bottomIdx])
|
|
305
|
+
: roundedBorder(width, "bottom", borderPaint, baseLines[bottomIdx]));
|
|
227
306
|
|
|
228
307
|
for (let i = bottomIdx + 1; i < baseLines.length; i++) {
|
|
229
308
|
result.push(baseLines[i]!);
|
|
@@ -238,6 +317,7 @@ export function installEditor(
|
|
|
238
317
|
ctx: ExtensionContext,
|
|
239
318
|
cursorStyle: CursorStyle = "block",
|
|
240
319
|
wheelScrollLines = DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
|
|
320
|
+
inlineFooter?: InlineFooterRenderer,
|
|
241
321
|
) {
|
|
242
322
|
let activeTui: TUI | undefined;
|
|
243
323
|
let activeEditor: OpenTuiEditor | undefined;
|
|
@@ -255,7 +335,7 @@ export function installEditor(
|
|
|
255
335
|
hiddenThinkingTarget = undefined;
|
|
256
336
|
applyFullscreenWheelScrollLines(tui, currentWheelScrollLines);
|
|
257
337
|
previousHardwareCursor = tui.getShowHardwareCursor();
|
|
258
|
-
activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle);
|
|
338
|
+
activeEditor = new OpenTuiEditor(tui, editorTheme, keybindings, currentCursorStyle, inlineFooter);
|
|
259
339
|
return activeEditor;
|
|
260
340
|
});
|
|
261
341
|
return {
|
package/extensions/footer.ts
CHANGED
|
@@ -204,141 +204,241 @@ export interface FooterHooks {
|
|
|
204
204
|
scheduleGitRefresh: () => void;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
interface FooterDataLike {
|
|
208
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface InlineFooterLine {
|
|
212
|
+
left: string;
|
|
213
|
+
right: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface InlineFooterLines {
|
|
217
|
+
top: InlineFooterLine;
|
|
218
|
+
bottom: InlineFooterLine;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Split the bottom border content the way `alignRight` splits the plain row:
|
|
223
|
+
* the right block (statistics) survives, the left one (model) shrinks to an
|
|
224
|
+
* ellipsis and only then disappears. Keeps the assembled border inside its
|
|
225
|
+
* budget so editor-side truncation never eats the right corner.
|
|
226
|
+
*/
|
|
227
|
+
function fitInlineLine(left: string, right: string, width: number, theme: Theme): InlineFooterLine {
|
|
228
|
+
const rightWidth = visibleWidth(right);
|
|
229
|
+
if (rightWidth > width) {
|
|
230
|
+
return { left: "", right: truncateToWidth(right, width, theme.fg("dim", "...")) };
|
|
231
|
+
}
|
|
232
|
+
const availableForLeft = width - rightWidth - 1;
|
|
233
|
+
if (availableForLeft <= 0) return { left: "", right };
|
|
234
|
+
return { left: truncateToWidth(left, availableForLeft, theme.fg("dim", "...")), right };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface FooterHandle {
|
|
238
|
+
renderInline(width: number): InlineFooterLines | undefined;
|
|
239
|
+
cleanup(): void;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function fitInlineSegments(
|
|
243
|
+
parts: readonly PrioritizedSegment[],
|
|
244
|
+
width: number,
|
|
245
|
+
theme: Theme,
|
|
246
|
+
): string {
|
|
247
|
+
const separator = theme.fg("dim", " · ");
|
|
248
|
+
const separatorReserve = Math.max(0, parts.length - 1) * Math.max(0, visibleWidth(separator) - 1);
|
|
249
|
+
return fitSegmentsByPriority(
|
|
250
|
+
parts,
|
|
251
|
+
Math.max(0, width - separatorReserve),
|
|
252
|
+
theme.fg("dim", "..."),
|
|
253
|
+
).join(separator);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function renderFooterContent(
|
|
257
|
+
ctx: ExtensionContext,
|
|
258
|
+
getState: () => FooterState,
|
|
259
|
+
getConfig: () => OpenTuiConfig,
|
|
260
|
+
getModelMeta: () => ModelMeta,
|
|
261
|
+
theme: Theme,
|
|
262
|
+
footerData: FooterDataLike,
|
|
263
|
+
width: number,
|
|
264
|
+
): { mainLines: [string, string]; inlineLines: InlineFooterLines; extensionLines: string[] } {
|
|
265
|
+
if (width <= 0) {
|
|
266
|
+
return {
|
|
267
|
+
mainLines: ["", ""],
|
|
268
|
+
inlineLines: {
|
|
269
|
+
top: { left: "", right: "" },
|
|
270
|
+
bottom: { left: "", right: "" },
|
|
271
|
+
},
|
|
272
|
+
extensionLines: [],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const state = getState();
|
|
277
|
+
const config = getConfig();
|
|
278
|
+
const glyphs = resolveGlyphs(config.icons.mode);
|
|
279
|
+
const segments = config.footerSegments;
|
|
280
|
+
const meta = getModelMeta();
|
|
281
|
+
const totals = getUsageTotals(ctx);
|
|
282
|
+
|
|
283
|
+
const leftParts: PrioritizedSegment[] = [];
|
|
284
|
+
const inlineTopLeftParts: PrioritizedSegment[] = [];
|
|
285
|
+
const inlineTopRightParts: PrioritizedSegment[] = [];
|
|
286
|
+
if (segments.cwd) {
|
|
287
|
+
const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
|
|
288
|
+
const cwd = formatCwd(ctx.sessionManager.getCwd());
|
|
289
|
+
const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
|
|
290
|
+
const accent = (text: string) => theme.fg("accent", text);
|
|
291
|
+
leftParts.push({
|
|
292
|
+
text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
|
|
293
|
+
compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
|
|
294
|
+
priority: 0,
|
|
295
|
+
truncate: (_text, maxWidth, ellipsis) => {
|
|
296
|
+
const pathWidth = maxWidth - visibleWidth(cwdPrefix);
|
|
297
|
+
if (pathWidth <= visibleWidth(ellipsis)) {
|
|
298
|
+
return truncateToWidth(`${cwdPrefix}${accent(basenamePath(cwd))}`, maxWidth, ellipsis);
|
|
299
|
+
}
|
|
300
|
+
return `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), pathWidth))}`;
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
304
|
+
}
|
|
305
|
+
if (segments.hostname) {
|
|
306
|
+
const shortHost = shortHostname(osHostname());
|
|
307
|
+
if (shortHost) {
|
|
308
|
+
leftParts.push({
|
|
309
|
+
text: `${theme.fg("dim", glyphs.host)} ${theme.fg("accent", shortHost)}`,
|
|
310
|
+
priority: 1,
|
|
311
|
+
});
|
|
312
|
+
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
316
|
+
if (sessionName) {
|
|
317
|
+
const sessionPart: PrioritizedSegment = {
|
|
318
|
+
text: `${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
|
|
319
|
+
priority: 2,
|
|
320
|
+
};
|
|
321
|
+
if (segments.sessionName) {
|
|
322
|
+
leftParts.push(sessionPart);
|
|
323
|
+
inlineTopLeftParts.push(sessionPart);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
|
|
327
|
+
if (gitSeg) {
|
|
328
|
+
leftParts.push({ text: gitSeg, priority: 3 });
|
|
329
|
+
inlineTopLeftParts.push(leftParts.at(-1)!);
|
|
330
|
+
}
|
|
331
|
+
if (segments.runtime) {
|
|
332
|
+
const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
|
|
333
|
+
if (runtimeSeg) {
|
|
334
|
+
leftParts.push({ text: runtimeSeg, priority: 4 });
|
|
335
|
+
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const timerSeg = renderTimerSegment(theme, state, glyphs);
|
|
339
|
+
if (timerSeg) {
|
|
340
|
+
leftParts.push({ text: timerSeg, priority: 1 });
|
|
341
|
+
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The context bar competes with the left segments for the same row:
|
|
345
|
+
// full bar first, then the compact icon+pct form, then dropped.
|
|
346
|
+
let contextText = "";
|
|
347
|
+
let contextCompact: string | undefined;
|
|
348
|
+
if (segments.context) {
|
|
349
|
+
contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
|
|
350
|
+
const compact = renderContextCompact(theme, ctx, glyphs);
|
|
351
|
+
if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
|
|
352
|
+
contextCompact = compact;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const allParts: PrioritizedSegment[] = [...leftParts];
|
|
356
|
+
if (contextText) {
|
|
357
|
+
// ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
|
|
358
|
+
allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
|
|
359
|
+
// Context stays at the far right; cwd is the first item in this group.
|
|
360
|
+
inlineTopRightParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
|
|
364
|
+
const fittedContext = contextText ? fitted.pop() ?? "" : "";
|
|
365
|
+
const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
|
|
366
|
+
|
|
367
|
+
const inlineLeftBudget = Math.floor(width * 0.45);
|
|
368
|
+
const inlineTopLeft = fitInlineSegments(inlineTopLeftParts, inlineLeftBudget, theme);
|
|
369
|
+
const inlineRightBudget = Math.max(0, width - visibleWidth(inlineTopLeft) - (inlineTopLeft ? 1 : 0));
|
|
370
|
+
const inlineTop: InlineFooterLine = {
|
|
371
|
+
left: inlineTopLeft,
|
|
372
|
+
right: fitInlineSegments(inlineTopRightParts, inlineRightBudget, theme),
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const modelParts: string[] = [];
|
|
376
|
+
modelParts.push(theme.fg("mdLink", glyphs.model));
|
|
377
|
+
if (meta.provider && meta.provider !== "Unknown") {
|
|
378
|
+
modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
|
|
379
|
+
}
|
|
380
|
+
modelParts.push(theme.fg("text", meta.model));
|
|
381
|
+
if (meta.effort && meta.effort !== "off") {
|
|
382
|
+
modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
|
|
383
|
+
}
|
|
384
|
+
const modelBlock = modelParts.join(theme.fg("dim", " · "));
|
|
385
|
+
const statsBlock = renderStatsBlock(theme, totals, glyphs, segments);
|
|
386
|
+
const inlineBottom = fitInlineLine(modelBlock, statsBlock, width, theme);
|
|
387
|
+
const line2 = alignRight(inlineBottom.left, inlineBottom.right, width, theme);
|
|
388
|
+
const mainLines: [string, string] = [line1, line2]
|
|
389
|
+
.map((line) => truncateToWidth(line, width, theme.fg("dim", "..."))) as [string, string];
|
|
390
|
+
const extensionLines = segments.extensionStatuses
|
|
391
|
+
? renderExtensionStatusLines(theme, footerData.getExtensionStatuses(), glyphs, width)
|
|
392
|
+
: [];
|
|
393
|
+
return { mainLines, inlineLines: { top: inlineTop, bottom: inlineBottom }, extensionLines };
|
|
394
|
+
}
|
|
395
|
+
|
|
207
396
|
export function installFooter(
|
|
208
397
|
ctx: ExtensionContext,
|
|
209
398
|
getState: () => FooterState,
|
|
210
399
|
getConfig: () => OpenTuiConfig,
|
|
211
400
|
getModelMeta: () => ModelMeta,
|
|
212
401
|
hooks: FooterHooks,
|
|
213
|
-
):
|
|
402
|
+
): FooterHandle {
|
|
403
|
+
let renderInline: (width: number) => InlineFooterLines | undefined = () => undefined;
|
|
404
|
+
|
|
214
405
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
215
406
|
hooks.setRequestRender(() => tui.requestRender());
|
|
216
407
|
const unsubBranch = footerData.onBranchChange(() => {
|
|
217
408
|
hooks.scheduleGitRefresh();
|
|
218
409
|
tui.requestRender();
|
|
219
410
|
});
|
|
411
|
+
const getContent = (width: number) => renderFooterContent(
|
|
412
|
+
ctx,
|
|
413
|
+
getState,
|
|
414
|
+
getConfig,
|
|
415
|
+
getModelMeta,
|
|
416
|
+
theme,
|
|
417
|
+
footerData,
|
|
418
|
+
width,
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
renderInline = (width) => getContent(width).inlineLines;
|
|
220
422
|
|
|
221
423
|
return {
|
|
222
424
|
dispose() {
|
|
223
425
|
unsubBranch();
|
|
224
426
|
hooks.setRequestRender(undefined);
|
|
427
|
+
renderInline = () => undefined;
|
|
225
428
|
},
|
|
226
429
|
invalidate() {},
|
|
227
430
|
render(width: number): string[] {
|
|
228
431
|
if (width <= 0) return [""];
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
const glyphs = resolveGlyphs(config.icons.mode);
|
|
232
|
-
const segments = config.footerSegments;
|
|
233
|
-
const meta = getModelMeta();
|
|
234
|
-
|
|
235
|
-
const totals = getUsageTotals(ctx);
|
|
236
|
-
|
|
237
|
-
const leftParts: PrioritizedSegment[] = [];
|
|
238
|
-
if (segments.cwd) {
|
|
239
|
-
const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
|
|
240
|
-
const cwd = formatCwd(ctx.sessionManager.getCwd());
|
|
241
|
-
const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
|
|
242
|
-
const accent = (text: string) => theme.fg("accent", text);
|
|
243
|
-
leftParts.push({
|
|
244
|
-
text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
|
|
245
|
-
compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
|
|
246
|
-
priority: 0,
|
|
247
|
-
truncate: (_text, maxWidth, ellipsis) => {
|
|
248
|
-
const pathWidth = maxWidth - visibleWidth(cwdPrefix);
|
|
249
|
-
if (pathWidth <= visibleWidth(ellipsis)) {
|
|
250
|
-
return truncateToWidth(`${cwdPrefix}${accent(basenamePath(cwd))}`, maxWidth, ellipsis);
|
|
251
|
-
}
|
|
252
|
-
return `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), pathWidth))}`;
|
|
253
|
-
},
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
if (segments.hostname) {
|
|
257
|
-
const shortHost = shortHostname(osHostname());
|
|
258
|
-
if (shortHost) {
|
|
259
|
-
leftParts.push({
|
|
260
|
-
text: `${theme.fg("dim", glyphs.host)} ${theme.fg("accent", shortHost)}`,
|
|
261
|
-
priority: 1,
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
if (segments.sessionName) {
|
|
266
|
-
const sessionName = ctx.sessionManager.getSessionName();
|
|
267
|
-
if (sessionName) {
|
|
268
|
-
leftParts.push({
|
|
269
|
-
text: `${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
|
|
270
|
-
priority: 2,
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
|
|
275
|
-
if (gitSeg) leftParts.push({ text: gitSeg, priority: 3 });
|
|
276
|
-
if (segments.runtime) {
|
|
277
|
-
const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
|
|
278
|
-
if (runtimeSeg) leftParts.push({ text: runtimeSeg, priority: 4 });
|
|
279
|
-
}
|
|
280
|
-
const timerSeg = renderTimerSegment(theme, state, glyphs);
|
|
281
|
-
if (timerSeg) leftParts.push({ text: timerSeg, priority: 1 });
|
|
282
|
-
|
|
283
|
-
// The context bar competes with the left segments for the same row:
|
|
284
|
-
// full bar first, then the compact icon+pct form, then dropped.
|
|
285
|
-
let contextText = "";
|
|
286
|
-
let contextCompact: string | undefined;
|
|
287
|
-
if (segments.context) {
|
|
288
|
-
contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
|
|
289
|
-
const compact = renderContextCompact(theme, ctx, glyphs);
|
|
290
|
-
if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
|
|
291
|
-
contextCompact = compact;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
const allParts: PrioritizedSegment[] = [...leftParts];
|
|
295
|
-
if (contextText) {
|
|
296
|
-
// ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
|
|
297
|
-
allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
|
|
301
|
-
const fittedContext = contextText ? fitted.pop() ?? "" : "";
|
|
302
|
-
const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
|
|
303
|
-
|
|
304
|
-
const modelParts: string[] = [];
|
|
305
|
-
modelParts.push(theme.fg("mdLink", glyphs.model));
|
|
306
|
-
if (meta.provider && meta.provider !== "Unknown") {
|
|
307
|
-
modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
|
|
308
|
-
}
|
|
309
|
-
modelParts.push(theme.fg("text", meta.model));
|
|
310
|
-
if (meta.effort && meta.effort !== "off") {
|
|
311
|
-
modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
|
|
312
|
-
}
|
|
313
|
-
const modelBlock = modelParts.join(theme.fg("dim", " · "));
|
|
314
|
-
|
|
315
|
-
const statsBlock = renderStatsBlock(
|
|
316
|
-
theme,
|
|
317
|
-
totals,
|
|
318
|
-
glyphs,
|
|
319
|
-
segments,
|
|
320
|
-
);
|
|
321
|
-
|
|
322
|
-
const line2 = alignRight(modelBlock, statsBlock, width, theme);
|
|
323
|
-
|
|
324
|
-
const mainLines = [line1, line2]
|
|
325
|
-
.map((line) => truncateToWidth(line, width, theme.fg("dim", "...")));
|
|
326
|
-
return segments.extensionStatuses
|
|
327
|
-
? [
|
|
328
|
-
...mainLines,
|
|
329
|
-
...renderExtensionStatusLines(
|
|
330
|
-
theme,
|
|
331
|
-
footerData.getExtensionStatuses(),
|
|
332
|
-
glyphs,
|
|
333
|
-
width,
|
|
334
|
-
),
|
|
335
|
-
]
|
|
336
|
-
: mainLines;
|
|
432
|
+
const { mainLines, extensionLines } = getContent(width);
|
|
433
|
+
return getConfig().inlineFooter ? extensionLines : [...mainLines, ...extensionLines];
|
|
337
434
|
},
|
|
338
435
|
};
|
|
339
436
|
});
|
|
340
437
|
|
|
341
|
-
return
|
|
342
|
-
|
|
438
|
+
return {
|
|
439
|
+
renderInline: (width) => renderInline(width),
|
|
440
|
+
cleanup() {
|
|
441
|
+
ctx.ui.setFooter(undefined);
|
|
442
|
+
},
|
|
343
443
|
};
|
|
344
444
|
}
|
package/extensions/header.ts
CHANGED
|
@@ -171,7 +171,7 @@ export class OpenTuiHeader implements Component {
|
|
|
171
171
|
this.ctx = ctx;
|
|
172
172
|
const pool = collectPiCommandNames(pi.getCommands());
|
|
173
173
|
this.tipCommands = pickSlashCommandTips(pool, {
|
|
174
|
-
fixed: ["
|
|
174
|
+
fixed: ["open-tui"],
|
|
175
175
|
count: 3,
|
|
176
176
|
});
|
|
177
177
|
}
|
package/extensions/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* thinking peek preview, rounded tool frames, and turn telemetry.
|
|
5
5
|
*
|
|
6
6
|
* Integrated from:
|
|
7
|
-
* - pi-open-tui (v0.3.
|
|
7
|
+
* - pi-open-tui (v0.3.9, MIT, by OldSun, https://github.com/OldSuns/pi-open-tui)
|
|
8
8
|
* - pi-rounded-tools (v0.1.3, MIT, by OrionPax, https://github.com/orionpax1997/pi-rounded-tools)
|
|
9
9
|
*
|
|
10
10
|
* Security audit notes:
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { type OpenTuiConfig, DEFAULT_CONFIG, ensureConfigExists, loadConfig, saveConfig } from "./config.ts";
|
|
19
19
|
import { installEditor } from "./editor.ts";
|
|
20
|
-
import { installFooter } from "./footer.ts";
|
|
20
|
+
import { installFooter, type FooterHandle } from "./footer.ts";
|
|
21
21
|
import { installHeader } from "./header.ts";
|
|
22
22
|
import { emptyGitStatus, readGitStatus } from "./git.ts";
|
|
23
23
|
import { readRuntimeInfo } from "./runtime.ts";
|
|
@@ -71,7 +71,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
71
71
|
let requestFooterRender: (() => void) | undefined;
|
|
72
72
|
let workingTimer: ReturnType<typeof setInterval> | undefined;
|
|
73
73
|
let cleanupHeader: (() => void) | undefined;
|
|
74
|
-
let cleanupFooter:
|
|
74
|
+
let cleanupFooter: FooterHandle | undefined;
|
|
75
75
|
let editor: ReturnType<typeof installEditor> | undefined;
|
|
76
76
|
let pendingUiChange: PendingUiChange | undefined;
|
|
77
77
|
|
|
@@ -150,7 +150,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
150
150
|
}
|
|
151
151
|
if (!active) {
|
|
152
152
|
cleanupHeader = installHeader(pi, ctx);
|
|
153
|
-
|
|
153
|
+
const footer = installFooter(
|
|
154
154
|
ctx,
|
|
155
155
|
() => state,
|
|
156
156
|
() => config,
|
|
@@ -164,7 +164,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
164
164
|
},
|
|
165
165
|
},
|
|
166
166
|
);
|
|
167
|
-
|
|
167
|
+
cleanupFooter = footer;
|
|
168
|
+
editor = installEditor(
|
|
169
|
+
pi,
|
|
170
|
+
ctx,
|
|
171
|
+
config.cursorStyle,
|
|
172
|
+
config.fullscreen.wheelScrollLines,
|
|
173
|
+
{
|
|
174
|
+
enabled: () => config.inlineFooter,
|
|
175
|
+
render: footer.renderInline,
|
|
176
|
+
},
|
|
177
|
+
);
|
|
168
178
|
active = true;
|
|
169
179
|
}
|
|
170
180
|
};
|
|
@@ -173,7 +183,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
173
183
|
if (!isTuiContext(ctx)) return;
|
|
174
184
|
if (active) {
|
|
175
185
|
cleanupHeader?.();
|
|
176
|
-
cleanupFooter?.();
|
|
186
|
+
cleanupFooter?.cleanup();
|
|
177
187
|
editor?.cleanup();
|
|
178
188
|
cleanupHeader = undefined;
|
|
179
189
|
cleanupFooter = undefined;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Merged 2026-08 from the standalone local extension rounded-tools.ts (a
|
|
5
5
|
* localized copy of npm:pi-rounded-tools@0.1.3, MIT, by OrionPax,
|
|
6
6
|
* https://github.com/orionpax1997/pi-rounded-tools), integrated into pi-tui
|
|
7
|
-
* (based on https://github.com/OldSuns/pi-open-tui v0.3.
|
|
7
|
+
* (based on https://github.com/OldSuns/pi-open-tui v0.3.9, MIT, by
|
|
8
8
|
* OldSun) and gated behind `roundedTools` in pi-tui config
|
|
9
9
|
* (~/.pi/agent/pi-tui.json, default on).
|
|
10
10
|
*
|
|
@@ -34,6 +34,7 @@ const COPY = {
|
|
|
34
34
|
labels: {
|
|
35
35
|
enabled: "Enabled",
|
|
36
36
|
roundedTools: "Rounded tool frames",
|
|
37
|
+
inlineFooter: "Inline footer",
|
|
37
38
|
thinkingPeek: "Thinking peek",
|
|
38
39
|
language: "Language",
|
|
39
40
|
wheelScrollLines: "Mouse wheel speed",
|
|
@@ -74,6 +75,7 @@ const COPY = {
|
|
|
74
75
|
labels: {
|
|
75
76
|
enabled: "启用",
|
|
76
77
|
roundedTools: "圆角工具边框",
|
|
78
|
+
inlineFooter: "内联底栏",
|
|
77
79
|
thinkingPeek: "思考预览",
|
|
78
80
|
language: "语言",
|
|
79
81
|
wheelScrollLines: "鼠标滚轮速度",
|
|
@@ -181,6 +183,7 @@ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
|
|
|
181
183
|
return [
|
|
182
184
|
{ id: "enabled", label: copy.labels.enabled, currentValue: flag(config.enabled) },
|
|
183
185
|
{ id: "roundedTools", label: copy.labels.roundedTools, currentValue: flag(config.roundedTools) },
|
|
186
|
+
{ id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) },
|
|
184
187
|
{ id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
|
|
185
188
|
{
|
|
186
189
|
id: "wheelScrollLines",
|
|
@@ -249,6 +252,7 @@ function handleSettingChange(
|
|
|
249
252
|
if (tab === "features") {
|
|
250
253
|
if (itemId === "enabled") return toggleEnabled(config);
|
|
251
254
|
if (itemId === "roundedTools") return { ...config, roundedTools: !config.roundedTools };
|
|
255
|
+
if (itemId === "inlineFooter") return { ...config, inlineFooter: !config.inlineFooter };
|
|
252
256
|
if (itemId === "settingsLanguage") return toggleLanguage(config);
|
|
253
257
|
if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
|
|
254
258
|
}
|
package/extensions/utils.ts
CHANGED
|
@@ -43,23 +43,28 @@ export function truncateBranch(branch: string, maxLen: number): string {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export function truncatePath(path: string, maxLen: number): string {
|
|
46
|
-
|
|
46
|
+
// `maxLen` is a terminal-column budget, so every comparison here must use
|
|
47
|
+
// display width. Comparing String.length let wide (CJK) paths stay wider than
|
|
48
|
+
// the budget: for a basename of 4 characters but 8 columns, `truncatePath`
|
|
49
|
+
// returned it unchanged, so fitSegmentsByPriority's `while (totalW() > maxW)`
|
|
50
|
+
// loop never made progress and livelocked the renderer at 100% CPU.
|
|
51
|
+
if (visibleWidth(path) <= maxLen) return path;
|
|
47
52
|
if (maxLen <= 3) return "...".slice(0, maxLen);
|
|
48
53
|
const sepChar = path.includes("/") ? "/" : "\\";
|
|
49
54
|
const parts = path.split(/[\\/]/);
|
|
50
|
-
if (parts.length <= 2) return path
|
|
55
|
+
if (parts.length <= 2) return truncateToWidth(path, maxLen, "...");
|
|
51
56
|
// Keep first segment (e.g. ~) and as many trailing segments as fit.
|
|
52
57
|
const tail: string[] = [];
|
|
53
58
|
let tailLen = 0;
|
|
54
59
|
for (let i = parts.length - 1; i >= 1; i--) {
|
|
55
60
|
const seg = parts[i]!;
|
|
56
|
-
if (tailLen + seg
|
|
61
|
+
if (tailLen + visibleWidth(seg) + 4 > maxLen) break;
|
|
57
62
|
tail.unshift(seg);
|
|
58
|
-
tailLen += seg
|
|
63
|
+
tailLen += visibleWidth(seg) + 1;
|
|
59
64
|
}
|
|
60
65
|
const head = parts[0]!;
|
|
61
66
|
const result = `${head}${sepChar}...${sepChar}${tail.join(sepChar)}`;
|
|
62
|
-
return result
|
|
67
|
+
return visibleWidth(result) > maxLen ? truncateToWidth(result, maxLen, "...") : result;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
export function finiteOrZero(value: unknown): number {
|
|
@@ -158,7 +163,25 @@ export function fitSegmentsByPriority(
|
|
|
158
163
|
if (totalW() <= maxW) break;
|
|
159
164
|
}
|
|
160
165
|
}
|
|
166
|
+
// A segment whose `truncate` cannot actually shrink would otherwise spin here
|
|
167
|
+
// forever and block the render timer. Require each pass to make progress, and
|
|
168
|
+
// drop the worst segment when it does not, so layout always terminates.
|
|
169
|
+
let lastTotal = Infinity;
|
|
161
170
|
while (totalW() > maxW) {
|
|
171
|
+
const nowTotal = totalW();
|
|
172
|
+
if (nowTotal >= lastTotal) {
|
|
173
|
+
let worst = -1;
|
|
174
|
+
for (let i = 0; i < items.length; i++) {
|
|
175
|
+
if (items[i].text !== "" && (worst === -1 || items[i].priority < items[worst].priority)) {
|
|
176
|
+
worst = i;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (worst === -1) break;
|
|
180
|
+
items[worst].text = "";
|
|
181
|
+
items[worst].w = 0;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
lastTotal = nowTotal;
|
|
162
185
|
let target = -1;
|
|
163
186
|
for (let i = 0; i < items.length; i++) {
|
|
164
187
|
if (items[i].text !== "" && (target === -1 || items[i].priority < items[target].priority)) {
|