pi-tui-footer 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/extensions/config.ts +5 -0
- package/extensions/editor.ts +71 -3
- 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/package.json +1 -1
package/README.md
CHANGED
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
|
@@ -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 {
|
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.8, MIT, by OldSun, https://github.com/OldSuns/pi-open-tui)
|
|
8
8
|
* - pi-rounded-tools (v0.1.3, MIT, by OrionPax, https://github.com/orionpax1997/pi-rounded-tools)
|
|
9
9
|
*
|
|
10
10
|
* Security audit notes:
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { type OpenTuiConfig, DEFAULT_CONFIG, ensureConfigExists, loadConfig, saveConfig } from "./config.ts";
|
|
19
19
|
import { installEditor } from "./editor.ts";
|
|
20
|
-
import { installFooter } from "./footer.ts";
|
|
20
|
+
import { installFooter, type FooterHandle } from "./footer.ts";
|
|
21
21
|
import { installHeader } from "./header.ts";
|
|
22
22
|
import { emptyGitStatus, readGitStatus } from "./git.ts";
|
|
23
23
|
import { readRuntimeInfo } from "./runtime.ts";
|
|
@@ -71,7 +71,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
71
71
|
let requestFooterRender: (() => void) | undefined;
|
|
72
72
|
let workingTimer: ReturnType<typeof setInterval> | undefined;
|
|
73
73
|
let cleanupHeader: (() => void) | undefined;
|
|
74
|
-
let cleanupFooter:
|
|
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.8, MIT, by
|
|
8
8
|
* OldSun) and gated behind `roundedTools` in pi-tui config
|
|
9
9
|
* (~/.pi/agent/pi-tui.json, default on).
|
|
10
10
|
*
|
|
@@ -34,6 +34,7 @@ const COPY = {
|
|
|
34
34
|
labels: {
|
|
35
35
|
enabled: "Enabled",
|
|
36
36
|
roundedTools: "Rounded tool frames",
|
|
37
|
+
inlineFooter: "Inline footer",
|
|
37
38
|
thinkingPeek: "Thinking peek",
|
|
38
39
|
language: "Language",
|
|
39
40
|
wheelScrollLines: "Mouse wheel speed",
|
|
@@ -74,6 +75,7 @@ const COPY = {
|
|
|
74
75
|
labels: {
|
|
75
76
|
enabled: "启用",
|
|
76
77
|
roundedTools: "圆角工具边框",
|
|
78
|
+
inlineFooter: "内联底栏",
|
|
77
79
|
thinkingPeek: "思考预览",
|
|
78
80
|
language: "语言",
|
|
79
81
|
wheelScrollLines: "鼠标滚轮速度",
|
|
@@ -181,6 +183,7 @@ function buildFeaturesItems(config: OpenTuiConfig, copy: SettingsCopy): SettingI
|
|
|
181
183
|
return [
|
|
182
184
|
{ id: "enabled", label: copy.labels.enabled, currentValue: flag(config.enabled) },
|
|
183
185
|
{ id: "roundedTools", label: copy.labels.roundedTools, currentValue: flag(config.roundedTools) },
|
|
186
|
+
{ id: "inlineFooter", label: copy.labels.inlineFooter, currentValue: flag(config.inlineFooter) },
|
|
184
187
|
{ id: "settingsLanguage", label: copy.labels.language, currentValue: copy.values.languages[config.settingsLanguage] },
|
|
185
188
|
{
|
|
186
189
|
id: "wheelScrollLines",
|
|
@@ -249,6 +252,7 @@ function handleSettingChange(
|
|
|
249
252
|
if (tab === "features") {
|
|
250
253
|
if (itemId === "enabled") return toggleEnabled(config);
|
|
251
254
|
if (itemId === "roundedTools") return { ...config, roundedTools: !config.roundedTools };
|
|
255
|
+
if (itemId === "inlineFooter") return { ...config, inlineFooter: !config.inlineFooter };
|
|
252
256
|
if (itemId === "settingsLanguage") return toggleLanguage(config);
|
|
253
257
|
if (itemId === "thinkingPeek") return cycleThinkingPeek(config);
|
|
254
258
|
}
|