pi-open-tui 0.2.2 → 0.2.4
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);
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { VERSION, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
center,
|
|
5
|
+
collectPiCommandNames,
|
|
6
|
+
formatCwd,
|
|
7
|
+
formatModelLabel,
|
|
8
|
+
formatThinkingLabel,
|
|
9
|
+
headerColumnWidths,
|
|
10
|
+
padRight,
|
|
11
|
+
pickSlashCommandTips,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
visibleWidth,
|
|
14
|
+
} from "./utils.ts";
|
|
4
15
|
|
|
5
16
|
const LOGO_CELL = "███";
|
|
6
|
-
const LOGO_ANIMATION_MS = 80;
|
|
7
17
|
|
|
8
18
|
type LogoColor = "panel" | "cyan" | "red" | "green" | "orange" | "white" | "flash" | "brand";
|
|
9
19
|
type LogoFrame = { phase: number; active: "left" | "top" | "right" | "none"; ax: number; ay: number; flash: boolean; white: boolean };
|
|
@@ -116,50 +126,107 @@ function renderLogo(frameIndex: number, paintBrand: (text: string) => string): s
|
|
|
116
126
|
});
|
|
117
127
|
}
|
|
118
128
|
|
|
129
|
+
function borderLine(
|
|
130
|
+
left: string,
|
|
131
|
+
label: string,
|
|
132
|
+
right: string,
|
|
133
|
+
width: number,
|
|
134
|
+
paint: (text: string) => string,
|
|
135
|
+
): string {
|
|
136
|
+
if (width <= 1) return "";
|
|
137
|
+
if (width < 8 || label.length === 0) {
|
|
138
|
+
return paint(truncateToWidth(left + "─".repeat(Math.max(0, width - 2)) + right, width, ""));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const before = "─── ";
|
|
142
|
+
const after = " ─────";
|
|
143
|
+
const fixedWidth = visibleWidth(before) + visibleWidth(label) + visibleWidth(after);
|
|
144
|
+
const fill = Math.max(0, width - 2 - fixedWidth);
|
|
145
|
+
return `${paint(left)}${paint(before)}${label}${paint(after)}${paint("─".repeat(fill))}${paint(right)}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function boxedLine(content: string, width: number, paint: (text: string) => string): string {
|
|
149
|
+
if (width <= 2) return truncateToWidth(content, width, "");
|
|
150
|
+
return `${paint("│")}${padRight(content, width - 2)}${paint("│")}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function twoColumn(
|
|
154
|
+
left: string,
|
|
155
|
+
right: string,
|
|
156
|
+
leftWidth: number,
|
|
157
|
+
rightWidth: number,
|
|
158
|
+
paint: (text: string) => string,
|
|
159
|
+
): string {
|
|
160
|
+
return `${padRight(left, leftWidth)} ${paint("│")} ${padRight(right, rightWidth, "…")}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
119
163
|
export class OpenTuiHeader implements Component {
|
|
120
|
-
private
|
|
121
|
-
private readonly timer: ReturnType<typeof setInterval>;
|
|
164
|
+
private readonly pi: ExtensionAPI;
|
|
122
165
|
private readonly ctx: ExtensionContext;
|
|
166
|
+
private readonly frame = LOGO_FRAMES.length - 1;
|
|
167
|
+
private readonly tipCommands: string[];
|
|
123
168
|
|
|
124
|
-
constructor(
|
|
169
|
+
constructor(pi: ExtensionAPI, ctx: ExtensionContext, _tui: TUI) {
|
|
170
|
+
this.pi = pi;
|
|
125
171
|
this.ctx = ctx;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
clearInterval(this.timer);
|
|
132
|
-
}
|
|
133
|
-
}, LOGO_ANIMATION_MS);
|
|
134
|
-
this.timer.unref?.();
|
|
172
|
+
const pool = collectPiCommandNames(pi.getCommands());
|
|
173
|
+
this.tipCommands = pickSlashCommandTips(pool, {
|
|
174
|
+
fixed: ["open-tui"],
|
|
175
|
+
count: 3,
|
|
176
|
+
});
|
|
135
177
|
}
|
|
136
178
|
|
|
137
179
|
render(width: number): string[] {
|
|
138
180
|
const theme = this.ctx.ui.theme;
|
|
139
181
|
const paint = (s: string) => theme.fg("accent", s);
|
|
140
182
|
const muted = (s: string) => theme.fg("muted", s);
|
|
183
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
141
184
|
const bold = (s: string) => theme.bold(s);
|
|
142
185
|
|
|
143
186
|
if (width < 24) return [paint(`Pi v${VERSION}`)];
|
|
144
187
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
188
|
+
const innerWidth = width - 2;
|
|
189
|
+
const { leftWidth, rightWidth, useTips } = headerColumnWidths(innerWidth);
|
|
190
|
+
const model = formatModelLabel(this.ctx.model);
|
|
191
|
+
const effort = formatThinkingLabel(this.pi.getThinkingLevel());
|
|
192
|
+
const cwd = formatCwd(this.ctx.cwd);
|
|
193
|
+
|
|
194
|
+
const leftLines = [
|
|
195
|
+
...renderLogo(this.frame, paint).map((line) => center(line, leftWidth)),
|
|
196
|
+
center(bold("Let's build something great"), leftWidth),
|
|
197
|
+
center(muted(`${model} · ${effort}`), leftWidth),
|
|
198
|
+
center(dim(cwd), leftWidth),
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
const tipDivider = paint("─".repeat(Math.max(8, Math.min(rightWidth, 22))));
|
|
202
|
+
const [cmd0 = "", cmd1 = "", cmd2 = "", cmd3 = ""] = this.tipCommands;
|
|
203
|
+
const tipLines = [
|
|
204
|
+
"",
|
|
205
|
+
paint(bold("Welcome")),
|
|
206
|
+
muted("Ask Pi anything"),
|
|
207
|
+
tipDivider,
|
|
208
|
+
paint(bold("Commands")),
|
|
209
|
+
muted(cmd0),
|
|
210
|
+
muted(cmd1),
|
|
211
|
+
muted(cmd2),
|
|
212
|
+
muted(cmd3),
|
|
213
|
+
"",
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
const lines = [borderLine("╭", `${paint("Pi")} v${VERSION}`, "╮", width, paint)];
|
|
217
|
+
for (let i = 0; i < leftLines.length; i++) {
|
|
218
|
+
const content = useTips
|
|
219
|
+
? twoColumn(leftLines[i] ?? "", tipLines[i] ?? "", leftWidth, rightWidth, paint)
|
|
220
|
+
: padRight(leftLines[i] ?? "", leftWidth);
|
|
221
|
+
lines.push(boxedLine(content, width, paint));
|
|
151
222
|
}
|
|
152
|
-
|
|
153
|
-
lines.push(center(bold("Let's build something great"), width));
|
|
154
|
-
|
|
223
|
+
lines.push(borderLine("╰", "", "╯", width, paint));
|
|
155
224
|
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
156
225
|
}
|
|
157
226
|
|
|
158
227
|
invalidate(): void {}
|
|
159
228
|
|
|
160
|
-
dispose(): void {
|
|
161
|
-
clearInterval(this.timer);
|
|
162
|
-
}
|
|
229
|
+
dispose(): void {}
|
|
163
230
|
}
|
|
164
231
|
|
|
165
232
|
export function installHeader(pi: ExtensionAPI, ctx: ExtensionContext): () => void {
|
|
@@ -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";
|
|
@@ -155,3 +220,110 @@ export function sanitizeStatus(text: string): string {
|
|
|
155
220
|
.replace(/ +/g, " ")
|
|
156
221
|
.trim();
|
|
157
222
|
}
|
|
223
|
+
|
|
224
|
+
export function formatThinkingLabel(level: string): string {
|
|
225
|
+
if (level === "off") return "thinking off";
|
|
226
|
+
return `${level} effort`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const PI_BUILTIN_SLASH_COMMAND_NAMES = [
|
|
230
|
+
"settings",
|
|
231
|
+
"model",
|
|
232
|
+
"scoped-models",
|
|
233
|
+
"export",
|
|
234
|
+
"import",
|
|
235
|
+
"share",
|
|
236
|
+
"copy",
|
|
237
|
+
"name",
|
|
238
|
+
"session",
|
|
239
|
+
"changelog",
|
|
240
|
+
"hotkeys",
|
|
241
|
+
"fork",
|
|
242
|
+
"clone",
|
|
243
|
+
"tree",
|
|
244
|
+
"trust",
|
|
245
|
+
"login",
|
|
246
|
+
"logout",
|
|
247
|
+
"new",
|
|
248
|
+
"compact",
|
|
249
|
+
"resume",
|
|
250
|
+
"reload",
|
|
251
|
+
"quit",
|
|
252
|
+
] as const;
|
|
253
|
+
|
|
254
|
+
export function collectPiCommandNames(sessionCommands: readonly { name: string }[]): string[] {
|
|
255
|
+
const names = new Set<string>(PI_BUILTIN_SLASH_COMMAND_NAMES);
|
|
256
|
+
for (const command of sessionCommands) {
|
|
257
|
+
if (command.name) names.add(command.name);
|
|
258
|
+
}
|
|
259
|
+
return [...names];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function pickSlashCommandTips(
|
|
263
|
+
availableNames: readonly string[],
|
|
264
|
+
options: {
|
|
265
|
+
fixed?: readonly string[];
|
|
266
|
+
count?: number;
|
|
267
|
+
exclude?: readonly string[];
|
|
268
|
+
random?: () => number;
|
|
269
|
+
} = {},
|
|
270
|
+
): string[] {
|
|
271
|
+
const fixed = [...(options.fixed ?? [])];
|
|
272
|
+
const count = options.count ?? 3;
|
|
273
|
+
const exclude = new Set<string>([...(options.exclude ?? []), ...fixed]);
|
|
274
|
+
const random = options.random ?? Math.random;
|
|
275
|
+
|
|
276
|
+
const pool = [...new Set(availableNames.map((n) => n.trim()).filter(Boolean))].filter(
|
|
277
|
+
(name) => !exclude.has(name),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
for (let i = pool.length - 1; i > 0; i--) {
|
|
281
|
+
const j = Math.floor(random() * (i + 1));
|
|
282
|
+
const tmp = pool[i]!;
|
|
283
|
+
pool[i] = pool[j]!;
|
|
284
|
+
pool[j] = tmp;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const picked = pool.slice(0, Math.max(0, count));
|
|
288
|
+
return [...fixed, ...picked].map((name) => (name.startsWith("/") ? name : `/${name}`));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export const MIN_LEFT_WIDTH = 28;
|
|
292
|
+
export const MIN_TIPS_WIDTH = 16;
|
|
293
|
+
export const MAX_TIPS_WIDTH = 28;
|
|
294
|
+
const COLUMN_GAP = 3;
|
|
295
|
+
|
|
296
|
+
export function headerColumnWidths(
|
|
297
|
+
innerWidth: number,
|
|
298
|
+
minTipsWidth = MIN_TIPS_WIDTH,
|
|
299
|
+
maxTipsWidth = MAX_TIPS_WIDTH,
|
|
300
|
+
minLeftWidth = MIN_LEFT_WIDTH,
|
|
301
|
+
): { leftWidth: number; rightWidth: number; useTips: boolean } {
|
|
302
|
+
if (innerWidth <= 0) {
|
|
303
|
+
return { leftWidth: 0, rightWidth: 0, useTips: false };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const gap = COLUMN_GAP;
|
|
307
|
+
if (innerWidth < minLeftWidth + gap + minTipsWidth) {
|
|
308
|
+
return { leftWidth: innerWidth, rightWidth: 0, useTips: false };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let rightWidth = Math.min(maxTipsWidth, Math.max(minTipsWidth, Math.round(innerWidth * 0.28)));
|
|
312
|
+
let leftWidth = innerWidth - gap - rightWidth;
|
|
313
|
+
|
|
314
|
+
if (leftWidth < minLeftWidth) {
|
|
315
|
+
leftWidth = minLeftWidth;
|
|
316
|
+
rightWidth = innerWidth - gap - leftWidth;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (leftWidth <= rightWidth) {
|
|
320
|
+
leftWidth = Math.ceil((innerWidth - gap) * 0.65);
|
|
321
|
+
rightWidth = innerWidth - gap - leftWidth;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (rightWidth < minTipsWidth || leftWidth < minLeftWidth) {
|
|
325
|
+
return { leftWidth: innerWidth, rightWidth: 0, useTips: false };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return { leftWidth, rightWidth, useTips: true };
|
|
329
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-open-tui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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
6
|
"keywords": [
|
|
@@ -55,6 +55,8 @@
|
|
|
55
55
|
"allowScripts": {
|
|
56
56
|
"protobufjs@7.6.5": true,
|
|
57
57
|
"protobufjs@7.6.4": true,
|
|
58
|
-
"@google/genai@1.52.0": true
|
|
58
|
+
"@google/genai@1.52.0": true,
|
|
59
|
+
"koffi@2.16.2": true,
|
|
60
|
+
"tree-sitter-bash@0.25.1": true
|
|
59
61
|
}
|
|
60
62
|
}
|