@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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0] - 2026-05-22
4
+
5
+ ## [0.10.9] - 2026-05-18
6
+
3
7
  ## [0.10.8] - 2026-05-18
4
8
 
5
9
  ## [0.10.6] - 2026-05-15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agnishc/edb-global-footer",
3
- "version": "0.10.8",
3
+ "version": "0.12.0",
4
4
  "description": "Pi extension: two-line status footer showing path, git branch, token usage, and model",
5
5
  "keywords": [
6
6
  "pi-package",
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 !== "(detached)" ? head : null;
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
+ });
@@ -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 showCompletion(totalMs: number): void {
633
- if (!ctx) return;
634
- cancelCompletionTimer();
635
-
636
- const theme = ctx.ui.theme;
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
- completionTimer = setTimeout(() => {
648
- completionTimer = null;
649
- ref.currentLine = undefined;
650
- ctx?.ui.setWidget("wi-completion", undefined);
651
- }, 3000);
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 };