@agnishc/edb-global-footer 0.10.8 → 0.12.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/CHANGELOG.md +4 -0
- package/package.json +1 -1
- package/src/footer.ts +28 -0
- package/src/git.ts +1 -1
- package/src/index.test.ts +134 -0
- package/src/workingIndicator.ts +46 -19
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/footer.ts
CHANGED
|
@@ -19,6 +19,28 @@ import type { GitStatus } from "./types";
|
|
|
19
19
|
// - Keys for extensions whose status you want to hide
|
|
20
20
|
const STATUS_KEY_BLACKLIST = new Set<string>(["extmgr", "sm"]);
|
|
21
21
|
|
|
22
|
+
// ── Agent mode helper ─────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const AGENT_MODE_ENTRY_TYPE = "agent-mode:active";
|
|
25
|
+
|
|
26
|
+
function getActiveAgentMode(entries: any[]): string | null {
|
|
27
|
+
// Scan from most recent to oldest
|
|
28
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
29
|
+
const entry = entries[i];
|
|
30
|
+
if (entry.type === "custom" && entry.customType === AGENT_MODE_ENTRY_TYPE) {
|
|
31
|
+
const mode = entry.data?.mode;
|
|
32
|
+
if (mode === null) {
|
|
33
|
+
// Clear marker — stop scanning, return no mode
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (typeof mode === "string" && mode.length > 0) {
|
|
37
|
+
return mode;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
22
44
|
// ── Live state types ───────────────────────────────────────────────────────────
|
|
23
45
|
|
|
24
46
|
interface TpsState {
|
|
@@ -169,6 +191,12 @@ export function createFooterRenderer(
|
|
|
169
191
|
modelText += `${sep}${theme.fg("dim", thinkLabel)}`;
|
|
170
192
|
}
|
|
171
193
|
}
|
|
194
|
+
|
|
195
|
+
// Append active agent mode after thinking label
|
|
196
|
+
const agentMode = getActiveAgentMode(ctx.sessionManager.getEntries());
|
|
197
|
+
if (agentMode) {
|
|
198
|
+
modelText += `${sep}${theme.fg("muted", `◈ ${agentMode}`)}`;
|
|
199
|
+
}
|
|
172
200
|
rightPartsLine2.push(modelText);
|
|
173
201
|
}
|
|
174
202
|
const statsLine = renderFooterLine(width, leftStats, rightPartsLine2.join(sep));
|
package/src/git.ts
CHANGED
|
@@ -13,7 +13,7 @@ export function parseGitStatus(output: string): GitStatus {
|
|
|
13
13
|
if (!line) continue;
|
|
14
14
|
if (line.startsWith("# branch.head ")) {
|
|
15
15
|
const head = line.slice("# branch.head ".length).trim();
|
|
16
|
-
branch = head && head
|
|
16
|
+
branch = head && !head.startsWith("(detached") ? head : null;
|
|
17
17
|
continue;
|
|
18
18
|
}
|
|
19
19
|
if (line.startsWith("# branch.ab ")) {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatThinkingLabel, formatTokens, sanitizeStatusText } from "./format.js";
|
|
3
|
+
import { parseGitStatus } from "./git.js";
|
|
4
|
+
import { withIcon } from "./icons.js";
|
|
5
|
+
import { formatTps } from "./tps.js";
|
|
6
|
+
|
|
7
|
+
describe("formatTokens", () => {
|
|
8
|
+
it("formats small numbers without suffix", () => {
|
|
9
|
+
expect(formatTokens(0)).toBe("0");
|
|
10
|
+
expect(formatTokens(999)).toBe("999");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("formats K suffix for thousands", () => {
|
|
14
|
+
expect(formatTokens(1000)).toBe("1.0k");
|
|
15
|
+
expect(formatTokens(1500)).toBe("1.5k");
|
|
16
|
+
expect(formatTokens(9500)).toBe("9.5k");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("formats M suffix for millions", () => {
|
|
20
|
+
expect(formatTokens(1_000_000)).toBe("1.0M");
|
|
21
|
+
expect(formatTokens(1_500_000)).toBe("1.5M");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("formats M suffix for billions", () => {
|
|
25
|
+
expect(formatTokens(1_000_000_000)).toBe("1000M");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("formatThinkingLabel", () => {
|
|
30
|
+
it("returns MI for minimal", () => {
|
|
31
|
+
expect(formatThinkingLabel("minimal")).toBe("MI");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns L for low", () => {
|
|
35
|
+
expect(formatThinkingLabel("low")).toBe("L");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns M for medium", () => {
|
|
39
|
+
expect(formatThinkingLabel("medium")).toBe("M");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns H for high", () => {
|
|
43
|
+
expect(formatThinkingLabel("high")).toBe("H");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns XH for xhigh", () => {
|
|
47
|
+
expect(formatThinkingLabel("xhigh")).toBe("XH");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns empty string for off", () => {
|
|
51
|
+
expect(formatThinkingLabel("off")).toBe("");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("sanitizeStatusText", () => {
|
|
56
|
+
it("replaces newlines with spaces", () => {
|
|
57
|
+
expect(sanitizeStatusText("hello\nworld")).toBe("hello world");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("replaces carriage returns with spaces", () => {
|
|
61
|
+
expect(sanitizeStatusText("hello\rworld")).toBe("hello world");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("replaces tabs with spaces", () => {
|
|
65
|
+
expect(sanitizeStatusText("hello\tworld")).toBe("hello world");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("collapses multiple spaces", () => {
|
|
69
|
+
expect(sanitizeStatusText("hello world")).toBe("hello world");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("handles combined edge cases", () => {
|
|
73
|
+
expect(sanitizeStatusText("hello\r\n\tworld")).toBe("hello world");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("parseGitStatus", () => {
|
|
78
|
+
it("parses branch from porcelain=v2 format", () => {
|
|
79
|
+
const output = `# branch.oid abc1234
|
|
80
|
+
# branch.head main
|
|
81
|
+
# branch.upstream origin/main
|
|
82
|
+
# branch.ab +1 -2
|
|
83
|
+
M file.txt`;
|
|
84
|
+
const result = parseGitStatus(output);
|
|
85
|
+
expect(result.branch).toBe("main");
|
|
86
|
+
expect(result.ahead).toBe(1);
|
|
87
|
+
expect(result.behind).toBe(2);
|
|
88
|
+
expect(result.dirty).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("handles detached HEAD", () => {
|
|
92
|
+
const output = `# branch.oid abc1234
|
|
93
|
+
# branch.head (detached at abc1234)
|
|
94
|
+
`;
|
|
95
|
+
const result = parseGitStatus(output);
|
|
96
|
+
expect(result.branch).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("marks dirty when non-comment lines present", () => {
|
|
100
|
+
const output = `# branch.oid abc1234
|
|
101
|
+
# branch.head main
|
|
102
|
+
M modified.txt`;
|
|
103
|
+
const result = parseGitStatus(output);
|
|
104
|
+
expect(result.dirty).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("withIcon", () => {
|
|
109
|
+
it("combines icon and text", () => {
|
|
110
|
+
expect(withIcon("🚀", "Launch")).toBe("🚀 Launch");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns text only when icon is empty", () => {
|
|
114
|
+
expect(withIcon("", "Launch")).toBe("Launch");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("formatTps", () => {
|
|
119
|
+
it("formats 0 tps as 0", () => {
|
|
120
|
+
expect(formatTps(0)).toBe("0");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("formats single tps", () => {
|
|
124
|
+
expect(formatTps(1)).toBe("1.0");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("formats multiple tps", () => {
|
|
128
|
+
expect(formatTps(10)).toBe("10");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("rounds large tps", () => {
|
|
132
|
+
expect(formatTps(15.7)).toBe("16");
|
|
133
|
+
});
|
|
134
|
+
});
|
package/src/workingIndicator.ts
CHANGED
|
@@ -455,6 +455,32 @@ function formatElapsed(ms: number): string {
|
|
|
455
455
|
return `${seconds}s`;
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
function stripAnsi(text: string): string {
|
|
459
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function isCompletionLine(line: string): boolean {
|
|
463
|
+
return /^✓ · .+ · \d/.test(stripAnsi(line).trim());
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function stripCompletionLine(text: string): string {
|
|
467
|
+
return text
|
|
468
|
+
.split(/\r?\n/)
|
|
469
|
+
.filter((line) => !isCompletionLine(line))
|
|
470
|
+
.join("\n");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function appendCompletionToFinalAssistantMessage(message: any, completionLine: string): void {
|
|
474
|
+
if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
|
|
475
|
+
const textBlocks = message.content.filter(
|
|
476
|
+
(block: any) => block?.type === "text" && typeof block.text === "string" && block.text.trim(),
|
|
477
|
+
);
|
|
478
|
+
const lastText = textBlocks[textBlocks.length - 1];
|
|
479
|
+
if (!lastText) return;
|
|
480
|
+
const text = stripCompletionLine(lastText.text);
|
|
481
|
+
lastText.text = `${text.trimEnd()}\n\n${completionLine}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
458
484
|
// ── Shimmer ───────────────────────────────────────────────────────────────────
|
|
459
485
|
|
|
460
486
|
const SHIMMER_BAND_WIDTH = 4;
|
|
@@ -629,26 +655,18 @@ export function installWorkingIndicator(pi: ExtensionAPI): WorkingIndicatorRef {
|
|
|
629
655
|
|
|
630
656
|
// ── Completion ────────────────────────────────────────────────────────────
|
|
631
657
|
|
|
632
|
-
function
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
const check = theme.fg("success", "✓");
|
|
638
|
-
const verb = theme.fg("accent", randomItem(COMPLETION_VERBS));
|
|
639
|
-
const time = theme.fg("dim", formatElapsed(totalMs));
|
|
640
|
-
const sep = theme.fg("dim", " · ");
|
|
641
|
-
const completionLine = `${check}${sep}${verb}${sep}${time}`;
|
|
642
|
-
|
|
643
|
-
// The Loader is torn down by pi on agent_end, so setWorkingMessage is a no-op.
|
|
644
|
-
// Use setWidget to briefly show the completion state above the editor.
|
|
645
|
-
ctx.ui.setWidget("wi-completion", [completionLine]);
|
|
658
|
+
function completionLine(totalMs: number): string {
|
|
659
|
+
// This string is persisted into assistant message content, so it must stay plain text.
|
|
660
|
+
// ANSI escapes inside persisted markdown can render as literal control-code fragments.
|
|
661
|
+
return `✓ · ${randomItem(COMPLETION_VERBS)} · ${formatElapsed(totalMs)}`;
|
|
662
|
+
}
|
|
646
663
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
664
|
+
function showCompletion(_totalMs: number): void {
|
|
665
|
+
// Completion is now persisted into the final assistant message on message_end.
|
|
666
|
+
// Do not use the old temporary wi-completion widget.
|
|
667
|
+
cancelCompletionTimer();
|
|
668
|
+
ref.currentLine = undefined;
|
|
669
|
+
ctx?.ui.setWidget("wi-completion", undefined);
|
|
652
670
|
}
|
|
653
671
|
|
|
654
672
|
// ── Spinner frames (apply to pi's working indicator widget) ───────────────
|
|
@@ -687,6 +705,15 @@ export function installWorkingIndicator(pi: ExtensionAPI): WorkingIndicatorRef {
|
|
|
687
705
|
startTimers();
|
|
688
706
|
});
|
|
689
707
|
|
|
708
|
+
pi.on("message_end", async (event: any, messageCtx) => {
|
|
709
|
+
ctx = messageCtx;
|
|
710
|
+
const message = event?.message;
|
|
711
|
+
if (message?.role !== "assistant") return;
|
|
712
|
+
if (message.stopReason === "toolUse") return;
|
|
713
|
+
const totalMs = startedAt > 0 ? Date.now() - startedAt : 0;
|
|
714
|
+
appendCompletionToFinalAssistantMessage(message, completionLine(totalMs));
|
|
715
|
+
});
|
|
716
|
+
|
|
690
717
|
pi.on("tool_execution_start", (event) => {
|
|
691
718
|
if (!isActive) return;
|
|
692
719
|
const e = event as { toolCallId?: string; toolName?: string };
|