pi-open-tui 0.2.3 → 0.2.5
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.
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
type ExtensionAPI,
|
|
4
4
|
type ExtensionContext,
|
|
5
5
|
type KeybindingsManager,
|
|
6
|
-
type Theme,
|
|
7
6
|
} from "@earendil-works/pi-coding-agent";
|
|
8
7
|
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
9
8
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
@@ -45,11 +44,13 @@ export class OpenTuiEditor extends CustomEditor {
|
|
|
45
44
|
tui: TUI,
|
|
46
45
|
editorTheme: EditorTheme,
|
|
47
46
|
keybindings: KeybindingsManager,
|
|
48
|
-
getUiTheme: () => Theme,
|
|
49
47
|
) {
|
|
50
48
|
super(tui, editorTheme, keybindings, { paddingX: 0 });
|
|
51
|
-
this.
|
|
52
|
-
|
|
49
|
+
// ponytail: route the frame through this.borderColor so Pi can recolor it
|
|
50
|
+
// via updateEditorBorderColor() — bash mode ("! " prefix → green) and
|
|
51
|
+
// thinking-level borders both flow through this one property.
|
|
52
|
+
this.getRail = () => this.borderColor("│");
|
|
53
|
+
this.getBorder = (s: string) => this.borderColor(s);
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
override setPaddingX(_padding: number): void {
|
|
@@ -62,9 +63,8 @@ export class OpenTuiEditor extends CustomEditor {
|
|
|
62
63
|
|
|
63
64
|
const rail = this.getRail();
|
|
64
65
|
const borderPaint = this.getBorder;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const innerWidth = Math.max(0, width - railWidth);
|
|
66
|
+
// ponytail: 1-char rail + 1-char gap on each side = 4 chars of chrome.
|
|
67
|
+
const innerWidth = Math.max(0, width - 4);
|
|
68
68
|
const baseLines = super.render(innerWidth);
|
|
69
69
|
const bottomIdx = findBottomBorderIndex(baseLines);
|
|
70
70
|
|
|
@@ -74,9 +74,9 @@ export class OpenTuiEditor extends CustomEditor {
|
|
|
74
74
|
for (let i = 1; i < bottomIdx; i++) {
|
|
75
75
|
const line = baseLines[i] ?? "";
|
|
76
76
|
if (isEditorBorderLine(line)) {
|
|
77
|
-
result.push(`${rail} ${fillLine("", innerWidth)}`);
|
|
77
|
+
result.push(`${rail} ${fillLine("", innerWidth)} ${rail}`);
|
|
78
78
|
} else {
|
|
79
|
-
result.push(`${rail} ${fillLine(line, innerWidth)}`);
|
|
79
|
+
result.push(`${rail} ${fillLine(line, innerWidth)} ${rail}`);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -91,9 +91,8 @@ export class OpenTuiEditor extends CustomEditor {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
export function installEditor(_pi: ExtensionAPI, ctx: ExtensionContext): () => void {
|
|
94
|
-
const getUiTheme = () => ctx.ui.theme;
|
|
95
94
|
ctx.ui.setEditorComponent((tui, editorTheme, keybindings) =>
|
|
96
|
-
new OpenTuiEditor(tui, editorTheme, keybindings
|
|
95
|
+
new OpenTuiEditor(tui, editorTheme, keybindings),
|
|
97
96
|
);
|
|
98
97
|
return () => {
|
|
99
98
|
ctx.ui.setEditorComponent(undefined);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
alignRight,
|
|
10
10
|
cacheHitColor,
|
|
11
11
|
effortColor,
|
|
12
|
+
fitSegmentsByPriority,
|
|
12
13
|
fmtTokens,
|
|
13
14
|
formatCwd,
|
|
14
15
|
formatDuration,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
providerColor,
|
|
17
18
|
sanitizeStatus,
|
|
18
19
|
stressColor,
|
|
20
|
+
truncatePath,
|
|
19
21
|
} from "./utils.ts";
|
|
20
22
|
import type { FooterState, ModelMeta, UsageTotals } from "./state.ts";
|
|
21
23
|
import { getUsageTotals } from "./state.ts";
|
|
@@ -39,35 +41,37 @@ function renderGitSegment(
|
|
|
39
41
|
git: GitStatus,
|
|
40
42
|
glyphs: IconGlyphs,
|
|
41
43
|
segments: OpenTuiConfig["footerSegments"],
|
|
44
|
+
maxBranchLen = 20,
|
|
42
45
|
): string {
|
|
43
46
|
const parts: string[] = [];
|
|
44
47
|
if (segments.gitBranch) {
|
|
45
48
|
if (git.branch) {
|
|
46
49
|
parts.push(theme.fg("mdLink", glyphs.git));
|
|
47
|
-
parts.push(theme.fg("
|
|
50
|
+
parts.push(theme.fg("mdLink", truncatePath(git.branch, maxBranchLen)));
|
|
48
51
|
} else if (git.commit?.detached) {
|
|
49
|
-
parts.push(theme.fg("
|
|
50
|
-
parts.push(theme.fg("
|
|
51
|
-
if (
|
|
52
|
+
parts.push(theme.fg("warning", glyphs.git));
|
|
53
|
+
parts.push(theme.fg("warning", "HEAD"));
|
|
54
|
+
if (git.commit.oid) {
|
|
52
55
|
const shortHash = git.commit.oid.slice(0, 7);
|
|
53
56
|
const tag = git.commit.tag ? ` ${git.commit.tag}` : "";
|
|
54
|
-
parts.push(theme.fg("
|
|
57
|
+
parts.push(theme.fg("dim", `${shortHash}${tag}`));
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
if (segments.gitStatus) {
|
|
60
63
|
const statusIcons: string[] = [];
|
|
64
|
+
// ponytail: always show count — `!1` not `!`, so 1 vs 100 is distinguishable.
|
|
61
65
|
const addStatus = (count: number, glyph: string, color: ThemeColor) => {
|
|
62
|
-
if (count > 0) statusIcons.push(theme.fg(color, `${glyph}${count
|
|
66
|
+
if (count > 0) statusIcons.push(theme.fg(color, `${glyph}${count}`));
|
|
63
67
|
};
|
|
64
68
|
addStatus(git.conflicted, glyphs.conflicted, "error");
|
|
65
|
-
addStatus(git.stashed, glyphs.stashed, "muted");
|
|
66
69
|
addStatus(git.deleted, glyphs.deleted, "error");
|
|
67
|
-
addStatus(git.renamed, glyphs.renamed, "warning");
|
|
68
70
|
addStatus(git.modified, glyphs.modified, "warning");
|
|
71
|
+
addStatus(git.renamed, glyphs.renamed, "warning");
|
|
69
72
|
addStatus(git.staged, glyphs.staged, "success");
|
|
70
73
|
addStatus(git.untracked, glyphs.untracked, "muted");
|
|
74
|
+
addStatus(git.stashed, glyphs.stashed, "muted");
|
|
71
75
|
|
|
72
76
|
if (git.ahead > 0 && git.behind > 0) {
|
|
73
77
|
statusIcons.push(theme.fg("warning", `${glyphs.diverged}${git.ahead}/${git.behind}`));
|
|
@@ -128,7 +132,7 @@ function renderContextBar(
|
|
|
128
132
|
const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
|
|
129
133
|
const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
|
|
130
134
|
const reserved = visibleWidth(contextIcon) + visibleWidth(pctText) + visibleWidth(ctxText) + 5 + 2;
|
|
131
|
-
const barWidth = Math.max(
|
|
135
|
+
const barWidth = Math.max(4, Math.min(12, width - reserved));
|
|
132
136
|
return `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(iconMode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
|
|
133
137
|
}
|
|
134
138
|
|
|
@@ -209,25 +213,32 @@ export function installFooter(
|
|
|
209
213
|
|
|
210
214
|
const totals = getUsageTotals(ctx);
|
|
211
215
|
|
|
212
|
-
const leftParts: string[] = [];
|
|
216
|
+
const leftParts: { text: string; priority: number }[] = [];
|
|
213
217
|
if (segments.cwd) {
|
|
214
|
-
|
|
218
|
+
const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
|
|
219
|
+
leftParts.push({
|
|
220
|
+
text: `${theme.fg("mdLink", glyphs.cwd)} ${theme.fg("accent", truncatePath(formatCwd(ctx.sessionManager.getCwd()), maxCwd))}`,
|
|
221
|
+
priority: 0,
|
|
222
|
+
});
|
|
215
223
|
}
|
|
216
224
|
const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
|
|
217
|
-
if (gitSeg) leftParts.push(gitSeg);
|
|
225
|
+
if (gitSeg) leftParts.push({ text: gitSeg, priority: 3 });
|
|
218
226
|
if (segments.runtime) {
|
|
219
227
|
const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
|
|
220
|
-
if (runtimeSeg) leftParts.push(runtimeSeg);
|
|
228
|
+
if (runtimeSeg) leftParts.push({ text: runtimeSeg, priority: 1 });
|
|
221
229
|
}
|
|
222
230
|
const timerSeg = renderTimerSegment(theme, state, glyphs);
|
|
223
|
-
if (timerSeg) leftParts.push(timerSeg);
|
|
231
|
+
if (timerSeg) leftParts.push({ text: timerSeg, priority: 2 });
|
|
224
232
|
|
|
225
233
|
let rightBlock = "";
|
|
226
234
|
if (segments.context) {
|
|
227
235
|
rightBlock = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
|
|
228
236
|
}
|
|
229
237
|
|
|
230
|
-
const
|
|
238
|
+
const rightW = visibleWidth(rightBlock);
|
|
239
|
+
const availLeft = Math.max(0, width - rightW - (rightBlock ? 1 : 0));
|
|
240
|
+
const fittedLeft = fitSegmentsByPriority(leftParts, availLeft, theme.fg("dim", "..."));
|
|
241
|
+
const line1 = alignRight(fittedLeft.join(" "), rightBlock, width, theme);
|
|
231
242
|
|
|
232
243
|
const modelParts: string[] = [];
|
|
233
244
|
modelParts.push(theme.fg("mdLink", glyphs.model));
|
|
@@ -83,7 +83,7 @@ export async function readGitStatus(
|
|
|
83
83
|
status.branch = undefined;
|
|
84
84
|
status.commit = { oid: null, detached: true, tag: null };
|
|
85
85
|
} else {
|
|
86
|
-
const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(ahead|behind) (\d+)\])
|
|
86
|
+
const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(ahead|behind) (\d+)\])?$/);
|
|
87
87
|
if (branchMatch) {
|
|
88
88
|
status.branch = branchMatch[1];
|
|
89
89
|
if (branchMatch[3] === "ahead") status.ahead = parseInt(branchMatch[4]!, 10);
|
|
@@ -101,7 +101,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
101
101
|
const generation = sessionLifecycle.currentGeneration();
|
|
102
102
|
const cwd = ctx.cwd;
|
|
103
103
|
const git = await readGitStatus(cwd, {
|
|
104
|
-
readCommit:
|
|
104
|
+
readCommit: true,
|
|
105
105
|
readTag: config.footerSegments.gitCommit,
|
|
106
106
|
});
|
|
107
107
|
if (!sessionLifecycle.isCurrent(generation)) return;
|
|
@@ -24,6 +24,26 @@ export function formatCwd(cwd: string): string {
|
|
|
24
24
|
return rel === "" ? "~" : `~${sep}${rel}`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export function truncatePath(path: string, maxLen: number): string {
|
|
28
|
+
if (path.length <= maxLen) return path;
|
|
29
|
+
if (maxLen <= 3) return "...".slice(0, maxLen);
|
|
30
|
+
const sepChar = path.includes("/") ? "/" : "\\";
|
|
31
|
+
const parts = path.split(/[\\/]/);
|
|
32
|
+
if (parts.length <= 2) return path.slice(0, maxLen - 3) + "...";
|
|
33
|
+
// Keep first segment (e.g. ~) and as many trailing segments as fit.
|
|
34
|
+
const tail: string[] = [];
|
|
35
|
+
let tailLen = 0;
|
|
36
|
+
for (let i = parts.length - 1; i >= 1; i--) {
|
|
37
|
+
const seg = parts[i]!;
|
|
38
|
+
if (tailLen + seg.length + 4 > maxLen) break;
|
|
39
|
+
tail.unshift(seg);
|
|
40
|
+
tailLen += seg.length + 1;
|
|
41
|
+
}
|
|
42
|
+
const head = parts[0]!;
|
|
43
|
+
const result = `${head}${sepChar}...${sepChar}${tail.join(sepChar)}`;
|
|
44
|
+
return result.length > maxLen ? result.slice(0, maxLen - 3) + "..." : result;
|
|
45
|
+
}
|
|
46
|
+
|
|
27
47
|
export function fmtTokens(n: number): string {
|
|
28
48
|
if (n < 1000) return n.toString();
|
|
29
49
|
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
|
|
@@ -70,6 +90,51 @@ export function alignRight(left: string, right: string, width: number, theme: Th
|
|
|
70
90
|
return truncatedLeft ? truncatedLeft + " " + right : right;
|
|
71
91
|
}
|
|
72
92
|
|
|
93
|
+
export type PrioritizedSegment = {
|
|
94
|
+
text: string;
|
|
95
|
+
priority: number;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pack segments into maxWidth, shrinking/dropping lowest-priority segments first.
|
|
100
|
+
* Higher priority = survives longer. Returns the surviving segment texts in
|
|
101
|
+
* original order, space-joined. Each segment is either kept whole, truncated
|
|
102
|
+
* with ellipsis, or dropped entirely.
|
|
103
|
+
*/
|
|
104
|
+
export function fitSegmentsByPriority(
|
|
105
|
+
segs: readonly PrioritizedSegment[],
|
|
106
|
+
maxW: number,
|
|
107
|
+
ellipsis = "...",
|
|
108
|
+
): string[] {
|
|
109
|
+
const items = segs.map((s) => ({ text: s.text, priority: s.priority, w: visibleWidth(s.text) }));
|
|
110
|
+
const totalW = () => {
|
|
111
|
+
const active = items.filter((it) => it.text !== "");
|
|
112
|
+
return active.reduce((a, it) => a + it.w, 0) + Math.max(0, active.length - 1);
|
|
113
|
+
};
|
|
114
|
+
while (totalW() > maxW) {
|
|
115
|
+
let target = -1;
|
|
116
|
+
for (let i = 0; i < items.length; i++) {
|
|
117
|
+
if (items[i].text !== "" && (target === -1 || items[i].priority < items[target].priority)) {
|
|
118
|
+
target = i;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (target === -1) break;
|
|
122
|
+
const others = items.filter((_, i) => i !== target && items[i].text !== "");
|
|
123
|
+
const otherW = others.reduce((a, it) => a + it.w, 0) + Math.max(0, others.length - 1);
|
|
124
|
+
const avail = maxW - otherW - (others.length > 0 ? 1 : 0);
|
|
125
|
+
if (avail <= visibleWidth(ellipsis)) {
|
|
126
|
+
items[target].text = "";
|
|
127
|
+
items[target].w = 0;
|
|
128
|
+
} else if (avail < items[target].w) {
|
|
129
|
+
items[target].text = truncateToWidth(items[target].text, avail, ellipsis);
|
|
130
|
+
items[target].w = visibleWidth(items[target].text);
|
|
131
|
+
} else {
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return items.filter((it) => it.text !== "").map((it) => it.text);
|
|
136
|
+
}
|
|
137
|
+
|
|
73
138
|
export function stressColor(value: number, warn = 70, danger = 90): ThemeColor {
|
|
74
139
|
if (value >= danger) return "error";
|
|
75
140
|
if (value >= warn) return "warning";
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-open-tui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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. Combines the best of pi-haiku, pi-claude-code-tui, and pi-zentui.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/OldSuns/pi-open-tui.git"
|
|
9
|
+
},
|
|
6
10
|
"keywords": [
|
|
7
11
|
"pi-package",
|
|
8
12
|
"pi",
|