pi-editor-footer 0.1.0
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/AGENTS.md +19 -0
- package/CHANGELOG.md +26 -0
- package/CONTEXT.md +29 -0
- package/README.md +84 -0
- package/docs/adr/0001-tracking-editor-for-skill-descriptions.md +18 -0
- package/docs/adr/0002-own-editor-slot-port-model-info-glow.md +18 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/reference/pi-tui-internals.md +144 -0
- package/docs/specs/01-config.md +57 -0
- package/docs/specs/02-identity.md +20 -0
- package/docs/specs/03-border-telemetry.md +48 -0
- package/docs/specs/04-header.md +30 -0
- package/docs/specs/05-footer.md +30 -0
- package/docs/specs/06-git.md +36 -0
- package/docs/specs/07-runtime.md +28 -0
- package/docs/specs/theme-overview.md +116 -0
- package/package.json +16 -0
- package/src/config.ts +184 -0
- package/src/detail-render.ts +119 -0
- package/src/footer.ts +479 -0
- package/src/git.ts +170 -0
- package/src/header.ts +185 -0
- package/src/icons.ts +197 -0
- package/src/index.ts +607 -0
- package/src/model-info.ts +341 -0
- package/src/runtime.ts +318 -0
- package/src/state.ts +144 -0
- package/src/telemetry.ts +437 -0
- package/src/theme-settings.ts +461 -0
- package/src/tracking-editor.ts +352 -0
- package/src/utils-workspace.ts +48 -0
- package/src/utils.ts +388 -0
- package/src/window-presentation.ts +56 -0
- package/test/config.test.ts +146 -0
- package/test/detail-render.test.ts +202 -0
- package/test/footer.test.ts +86 -0
- package/test/git.test.ts +45 -0
- package/test/header.test.ts +169 -0
- package/test/icons.test.ts +24 -0
- package/test/runtime.test.ts +71 -0
- package/test/telemetry.test.ts +199 -0
- package/test/utils.test.ts +71 -0
- package/test/window-presentation.test.ts +73 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
formatTurnTelemetry,
|
|
5
|
+
TurnTelemetryTracker,
|
|
6
|
+
type AssistantMessage,
|
|
7
|
+
type TelemetryConfig,
|
|
8
|
+
} from "../src/telemetry.js";
|
|
9
|
+
|
|
10
|
+
const theme = {
|
|
11
|
+
fg: (_color: string, text: string) => text,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const fullConfig: TelemetryConfig = {
|
|
15
|
+
enabled: true,
|
|
16
|
+
tps: true,
|
|
17
|
+
ttft: true,
|
|
18
|
+
duration: true,
|
|
19
|
+
tokens: true,
|
|
20
|
+
stalls: true,
|
|
21
|
+
cost: true,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function makeMessage(output = 20, input = 50): AssistantMessage {
|
|
25
|
+
const totalTokens = input + output;
|
|
26
|
+
return {
|
|
27
|
+
role: "assistant",
|
|
28
|
+
content: [{ type: "text", text: "response" }],
|
|
29
|
+
api: "openai-completions",
|
|
30
|
+
provider: "openai",
|
|
31
|
+
model: "gpt-4",
|
|
32
|
+
usage: {
|
|
33
|
+
input,
|
|
34
|
+
output,
|
|
35
|
+
totalTokens,
|
|
36
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: totalTokens * 0.000004 },
|
|
37
|
+
},
|
|
38
|
+
stopReason: "stop",
|
|
39
|
+
timestamp: Date.now(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function update(
|
|
44
|
+
message: AssistantMessage,
|
|
45
|
+
delta = "x",
|
|
46
|
+
type = "text_delta",
|
|
47
|
+
): { type: "message_update"; message: AssistantMessage; assistantMessageEvent: { type: string; delta: string } } {
|
|
48
|
+
return {
|
|
49
|
+
type: "message_update",
|
|
50
|
+
message: message as unknown as AssistantMessage & { role: string },
|
|
51
|
+
assistantMessageEvent: { type, delta },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function startTurn(tracker: TurnTelemetryTracker, message: AssistantMessage, turnIndex = 0): void {
|
|
56
|
+
tracker.handle({ type: "turn_start", turnIndex, timestamp: Date.now() });
|
|
57
|
+
tracker.handle({ type: "message_start", message: message as unknown as AssistantMessage & { role: string } });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function endTurn(tracker: TurnTelemetryTracker, message: AssistantMessage, turnIndex = 0) {
|
|
61
|
+
tracker.handle({ type: "message_end", message: message as unknown as AssistantMessage & { role: string } });
|
|
62
|
+
return tracker.handle({ type: "turn_end", turnIndex, message: message as unknown as AssistantMessage & { role: string }, toolResults: [] });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("TurnTelemetryTracker", () => {
|
|
66
|
+
test("uses total output over full generation time", () => {
|
|
67
|
+
let now = 0;
|
|
68
|
+
const tracker = new TurnTelemetryTracker(() => now);
|
|
69
|
+
const message = makeMessage();
|
|
70
|
+
startTurn(tracker, message);
|
|
71
|
+
for (const timestamp of [4000, 4100]) {
|
|
72
|
+
now = timestamp;
|
|
73
|
+
tracker.handle(update(message));
|
|
74
|
+
}
|
|
75
|
+
now = 5000;
|
|
76
|
+
const telemetry = endTurn(tracker, message);
|
|
77
|
+
assert.deepEqual(telemetry, {
|
|
78
|
+
tps: 4,
|
|
79
|
+
ttftMs: 4000,
|
|
80
|
+
totalMs: 5000,
|
|
81
|
+
inputTokens: 50,
|
|
82
|
+
outputTokens: 20,
|
|
83
|
+
stallMs: 0,
|
|
84
|
+
stallCount: 0,
|
|
85
|
+
rateUsdPerMTokens: 4,
|
|
86
|
+
generationMs: 5000,
|
|
87
|
+
totalTokens: 70,
|
|
88
|
+
costUsd: 0.00028,
|
|
89
|
+
measurementMs: 5000,
|
|
90
|
+
});
|
|
91
|
+
assert.equal(
|
|
92
|
+
formatTurnTelemetry(telemetry!, theme, fullConfig),
|
|
93
|
+
"> TPS 4.0 tok/s | ~ TTFT 4.0s | + 5.0s | ↑ 50 | ↓ 20 | $ $4.00/M",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("measures non-streamed responses from turn start", () => {
|
|
98
|
+
let now = 0;
|
|
99
|
+
const tracker = new TurnTelemetryTracker(() => now);
|
|
100
|
+
const message = makeMessage();
|
|
101
|
+
|
|
102
|
+
tracker.handle({ type: "turn_start", turnIndex: 0, timestamp: Date.now() });
|
|
103
|
+
now = 5000;
|
|
104
|
+
tracker.handle({ type: "message_start", message: message as unknown as AssistantMessage & { role: string } });
|
|
105
|
+
tracker.handle({ type: "message_end", message: message as unknown as AssistantMessage & { role: string } });
|
|
106
|
+
const telemetry = tracker.handle({ type: "turn_end", turnIndex: 0, message: message as unknown as AssistantMessage & { role: string }, toolResults: [] })!;
|
|
107
|
+
|
|
108
|
+
assert.equal(telemetry.tps, 4);
|
|
109
|
+
assert.equal(telemetry.ttftMs, 5000);
|
|
110
|
+
assert.equal(telemetry.generationMs, 5000);
|
|
111
|
+
assert.equal(telemetry.measurementMs, 5000);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("returns no TPS without output or generation time", () => {
|
|
115
|
+
const scenarios = [
|
|
116
|
+
{ name: "zero duration", updates: [0, 0], endMs: 0, output: 20 },
|
|
117
|
+
{ name: "zero output", updates: [100, 200], endMs: 800, output: 0 },
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
for (const scenario of scenarios) {
|
|
121
|
+
let now = 0;
|
|
122
|
+
const tracker = new TurnTelemetryTracker(() => now);
|
|
123
|
+
const message = makeMessage(scenario.output);
|
|
124
|
+
startTurn(tracker, message);
|
|
125
|
+
for (const timestamp of scenario.updates) {
|
|
126
|
+
now = timestamp;
|
|
127
|
+
tracker.handle(update(message));
|
|
128
|
+
}
|
|
129
|
+
now = scenario.endMs;
|
|
130
|
+
const telemetry = endTurn(tracker, message);
|
|
131
|
+
assert.equal(telemetry?.tps, null, scenario.name);
|
|
132
|
+
assert.equal(telemetry?.outputTokens, scenario.output, scenario.name);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("keeps stalls in delivery time so they lower TPS", () => {
|
|
137
|
+
function measure(updates: number[], endMs: number) {
|
|
138
|
+
let now = 0;
|
|
139
|
+
const tracker = new TurnTelemetryTracker(() => now);
|
|
140
|
+
const message = makeMessage();
|
|
141
|
+
startTurn(tracker, message);
|
|
142
|
+
for (const timestamp of updates) {
|
|
143
|
+
now = timestamp;
|
|
144
|
+
tracker.handle(update(message));
|
|
145
|
+
}
|
|
146
|
+
now = endMs;
|
|
147
|
+
return endTurn(tracker, message)!;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const uninterrupted = measure([100, 200, 300], 800);
|
|
151
|
+
const stalled = measure([100, 1200, 2300, 2400, 3500], 3600);
|
|
152
|
+
|
|
153
|
+
assert.equal(uninterrupted.tps, 25);
|
|
154
|
+
assert.equal(stalled.tps, 5.6);
|
|
155
|
+
assert.ok(stalled.tps! < uninterrupted.tps!);
|
|
156
|
+
assert.equal(stalled.stallMs, 3300);
|
|
157
|
+
assert.equal(stalled.stallCount, 2);
|
|
158
|
+
assert.match(formatTurnTelemetry(stalled, theme, fullConfig), /! stall 2x \/ 3\.3s/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("getLastTelemetry returns last turn", () => {
|
|
162
|
+
let now = 0;
|
|
163
|
+
const tracker = new TurnTelemetryTracker(() => now);
|
|
164
|
+
const message = makeMessage(10, 10);
|
|
165
|
+
startTurn(tracker, message);
|
|
166
|
+
now = 100;
|
|
167
|
+
tracker.handle(update(message));
|
|
168
|
+
now = 1000;
|
|
169
|
+
const tel = endTurn(tracker, message)!;
|
|
170
|
+
assert.deepEqual(tracker.getLastTelemetry(), tel);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("respects telemetry segment settings", () => {
|
|
174
|
+
const telemetry = {
|
|
175
|
+
tps: 50,
|
|
176
|
+
ttftMs: 200,
|
|
177
|
+
totalMs: 900,
|
|
178
|
+
inputTokens: 50,
|
|
179
|
+
outputTokens: 20,
|
|
180
|
+
stallMs: 800,
|
|
181
|
+
stallCount: 1,
|
|
182
|
+
rateUsdPerMTokens: 4,
|
|
183
|
+
generationMs: 700,
|
|
184
|
+
totalTokens: 70,
|
|
185
|
+
costUsd: 0.00028,
|
|
186
|
+
measurementMs: 400,
|
|
187
|
+
};
|
|
188
|
+
const hidden: TelemetryConfig = {
|
|
189
|
+
enabled: false,
|
|
190
|
+
tps: false,
|
|
191
|
+
ttft: false,
|
|
192
|
+
duration: false,
|
|
193
|
+
tokens: false,
|
|
194
|
+
stalls: false,
|
|
195
|
+
cost: false,
|
|
196
|
+
};
|
|
197
|
+
assert.equal(formatTurnTelemetry(telemetry, theme, hidden), "");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
basenamePath,
|
|
5
|
+
fitSegmentsByPriority,
|
|
6
|
+
formatCwd,
|
|
7
|
+
formatDuration,
|
|
8
|
+
fmtTokens,
|
|
9
|
+
truncateBranch,
|
|
10
|
+
truncatePath,
|
|
11
|
+
} from "../src/utils.js";
|
|
12
|
+
|
|
13
|
+
const theme = { fg: (_s: string, t: string) => t };
|
|
14
|
+
|
|
15
|
+
describe("utils", () => {
|
|
16
|
+
test("basenamePath extracts last segment", () => {
|
|
17
|
+
assert.equal(basenamePath("/a/b/c"), "c");
|
|
18
|
+
assert.equal(basenamePath("~/.pi/agent"), "agent");
|
|
19
|
+
assert.equal(basenamePath("single"), "single");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("truncateBranch keeps prefix", () => {
|
|
23
|
+
assert.equal(truncateBranch("fix/login", 20), "fix/login");
|
|
24
|
+
assert.equal(truncateBranch("a".repeat(30), 10), "a".repeat(7) + "...");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("truncatePath keeps head and tail", () => {
|
|
28
|
+
assert.equal(truncatePath("/a/b/c", 20), "/a/b/c");
|
|
29
|
+
const long = "~/a/b/c/d/e/f/g";
|
|
30
|
+
const truncated = truncatePath(long, 10);
|
|
31
|
+
assert.ok(truncated.length <= 10);
|
|
32
|
+
assert.ok(truncated.includes("..."));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("fmtTokens formats", () => {
|
|
36
|
+
assert.equal(fmtTokens(999), "999");
|
|
37
|
+
assert.equal(fmtTokens(1500), "1.5k");
|
|
38
|
+
assert.equal(fmtTokens(15000), "15k");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("formatDuration", () => {
|
|
42
|
+
assert.equal(formatDuration(500), "0s");
|
|
43
|
+
assert.equal(formatDuration(65000), "1m 5s");
|
|
44
|
+
assert.equal(formatDuration(3700000), "1h 1m 40s");
|
|
45
|
+
});
|
|
46
|
+
test("formatCwd relative to HOME", () => {
|
|
47
|
+
const cwd = formatCwd("/tmp");
|
|
48
|
+
assert.ok(typeof cwd === "string");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("fitSegmentsByPriority compacts and drops", () => {
|
|
52
|
+
const segs = [
|
|
53
|
+
{ text: "cwd", priority: 0 },
|
|
54
|
+
{ text: "git", priority: 3 },
|
|
55
|
+
{ text: "runtime", priority: 4 },
|
|
56
|
+
];
|
|
57
|
+
const fitted = fitSegmentsByPriority(segs, 20, "...");
|
|
58
|
+
assert.ok(fitted.length > 0);
|
|
59
|
+
const narrow = fitSegmentsByPriority(segs, 3, "...");
|
|
60
|
+
assert.ok(narrow.length <= segs.length);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("fitSegmentsByPriority uses compactText", () => {
|
|
64
|
+
const segs = [
|
|
65
|
+
{ text: "~/very/long/path/to/project", compactText: "project", priority: 4 },
|
|
66
|
+
{ text: "git: main", priority: 0 },
|
|
67
|
+
];
|
|
68
|
+
const fitted = fitSegmentsByPriority(segs, 15, "...");
|
|
69
|
+
assert.ok(fitted.some((s) => s.includes("project")));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import {
|
|
5
|
+
decorateWindow,
|
|
6
|
+
type WindowThemeLike,
|
|
7
|
+
} from "../src/window-presentation.js";
|
|
8
|
+
|
|
9
|
+
const RED = "\x1b[31m";
|
|
10
|
+
const RESET = "\x1b[39m";
|
|
11
|
+
|
|
12
|
+
/** Mock theme emitting REAL ANSI sequences, like pi's Theme does. */
|
|
13
|
+
function makeTheme(): WindowThemeLike {
|
|
14
|
+
return {
|
|
15
|
+
border: (s) => `${RED}${s}${RESET}`,
|
|
16
|
+
highlight: (s) => `${RED}${s}${RESET}`,
|
|
17
|
+
dim: (s) => `${RED}${s}${RESET}`,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("decorateWindow", () => {
|
|
22
|
+
test("returns [] for empty input (window hidden)", () => {
|
|
23
|
+
assert.deepEqual(decorateWindow([], 40, makeTheme()), []);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("builds a bordered box: border, header, body, border", () => {
|
|
27
|
+
const out = decorateWindow(
|
|
28
|
+
["grilling · skill", "Interview users"],
|
|
29
|
+
20,
|
|
30
|
+
makeTheme(),
|
|
31
|
+
);
|
|
32
|
+
assert.equal(out.length, 4);
|
|
33
|
+
// top/bottom borders: ┌ + (width-2) ─ + ┐
|
|
34
|
+
assert.equal(out[0], `┌${RED}${"─".repeat(18)}${RESET}┐`);
|
|
35
|
+
assert.equal(out[3], `└${RED}${"─".repeat(18)}${RESET}┘`);
|
|
36
|
+
// header row: highlighted header (16 cols = inner width, no pad)
|
|
37
|
+
assert.equal(out[1], `│ ${RED}grilling · skill${RESET} │`);
|
|
38
|
+
// body row: dim description (15 cols, padded by 1 + the row's suffix space)
|
|
39
|
+
assert.equal(out[2], `│ ${RED}Interview users${RESET} │`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("every row is exactly `width` columns (ANSI-aware padding)", () => {
|
|
43
|
+
const out = decorateWindow(["a · b", "line"], 12, makeTheme());
|
|
44
|
+
for (const row of out) {
|
|
45
|
+
assert.equal(
|
|
46
|
+
visibleWidth(row),
|
|
47
|
+
12,
|
|
48
|
+
`row ${JSON.stringify(row)} not 12 wide`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("padTo is ANSI-aware: styled content padded to inner width", () => {
|
|
54
|
+
// "a · b" (5 cols) padded to inner width (8) — 3 trailing spaces AFTER the reset
|
|
55
|
+
const out = decorateWindow(["a · b", "line"], 12, makeTheme());
|
|
56
|
+
assert.equal(out[1], `│ ${RED}a · b${RESET} │`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("short content pads, long content does not crash (clamped pad)", () => {
|
|
60
|
+
const out = decorateWindow(["label · kind", "short"], 10, makeTheme());
|
|
61
|
+
assert.equal(out.length, 4);
|
|
62
|
+
assert.ok(out[1].includes("label · kind"));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("header uses highlight, body uses dim, borders use border style", () => {
|
|
66
|
+
const out = decorateWindow(["h", "d1", "d2"], 8, makeTheme());
|
|
67
|
+
assert.ok(out[1].startsWith(`│ ${RED}h${RESET}`));
|
|
68
|
+
assert.ok(out[2].startsWith(`│ ${RED}d1${RESET}`));
|
|
69
|
+
assert.ok(out[3].startsWith(`│ ${RED}d2${RESET}`));
|
|
70
|
+
assert.ok(out[0].startsWith(`┌${RED}`));
|
|
71
|
+
assert.ok(out[out.length - 1].startsWith(`└${RED}`));
|
|
72
|
+
});
|
|
73
|
+
});
|
package/tsconfig.json
ADDED