@aaroncql/pim-agent 0.0.1

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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,161 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { createFooterWidget, getTotalCost } from "./index";
4
+ import type { GitState } from "./git";
5
+
6
+ function deferred<T>(): {
7
+ readonly promise: Promise<T>;
8
+ readonly resolve: (value: T) => void;
9
+ } {
10
+ let resolve!: (value: T) => void;
11
+ const promise = new Promise<T>((r) => {
12
+ resolve = r;
13
+ });
14
+ return { promise, resolve };
15
+ }
16
+
17
+ async function flushPromises(): Promise<void> {
18
+ await Promise.resolve();
19
+ await Promise.resolve();
20
+ }
21
+
22
+ function assistant(cost: number): unknown {
23
+ return {
24
+ type: "message",
25
+ message: {
26
+ role: "assistant",
27
+ usage: {
28
+ cost: {
29
+ total: cost,
30
+ },
31
+ },
32
+ },
33
+ };
34
+ }
35
+
36
+ describe("getTotalCost", () => {
37
+ test("sums assistant costs across all session entries", () => {
38
+ const ctx = {
39
+ sessionManager: {
40
+ getEntries: () => [
41
+ assistant(1.25),
42
+ {
43
+ type: "message",
44
+ message: {
45
+ role: "user",
46
+ },
47
+ },
48
+ assistant(2.5),
49
+ ],
50
+ },
51
+ } as unknown as ExtensionContext;
52
+
53
+ expect(getTotalCost(ctx)).toBe(3.75);
54
+ });
55
+ });
56
+
57
+ describe("createFooterWidget", () => {
58
+ test("coalesces git refreshes while one is in flight", async () => {
59
+ const first = deferred<GitState>();
60
+ const second = deferred<GitState>();
61
+ const fetches: Promise<GitState>[] = [];
62
+ let branchHandler: () => void = () => {};
63
+ let gitWatchHandler: () => void = () => {};
64
+ let branchUnsubscribed = false;
65
+ let gitWatchDisposed = false;
66
+ let renderRequests = 0;
67
+
68
+ const ctx = {
69
+ cwd: "/repo",
70
+ sessionManager: {
71
+ getEntries: () => [],
72
+ },
73
+ } as unknown as ExtensionContext;
74
+
75
+ const widget = createFooterWidget(
76
+ ctx,
77
+ {
78
+ requestRender: () => {
79
+ renderRequests++;
80
+ },
81
+ },
82
+ {
83
+ onBranchChange: (handler) => {
84
+ branchHandler = handler;
85
+ return () => {
86
+ branchUnsubscribed = true;
87
+ };
88
+ },
89
+ },
90
+ {
91
+ fetchGitStatus: () => {
92
+ const promise = fetches.length === 0 ? first.promise : second.promise;
93
+ fetches.push(promise);
94
+ return promise;
95
+ },
96
+ watchGitDir: (_cwd, handler) => {
97
+ gitWatchHandler = handler;
98
+ return () => {
99
+ gitWatchDisposed = true;
100
+ };
101
+ },
102
+ renderFooterLine: (_width, _ctx, gitState) => gitState.branch ?? "none",
103
+ getTotalCost: () => 0,
104
+ }
105
+ );
106
+
107
+ expect(fetches).toHaveLength(1);
108
+
109
+ branchHandler();
110
+ gitWatchHandler();
111
+ expect(fetches).toHaveLength(1);
112
+
113
+ first.resolve({ branch: "main", dirty: false, ahead: 0, behind: 0 });
114
+ await flushPromises();
115
+ expect(fetches).toHaveLength(2);
116
+
117
+ second.resolve({ branch: "next", dirty: true, ahead: 1, behind: 0 });
118
+ await flushPromises();
119
+ expect(fetches).toHaveLength(2);
120
+ expect(renderRequests).toBe(2);
121
+ expect(widget.render(80)).toEqual(["next"]);
122
+
123
+ widget.dispose();
124
+ expect(branchUnsubscribed).toBe(true);
125
+ expect(gitWatchDisposed).toBe(true);
126
+ });
127
+
128
+ test("requests render only when git state changes", async () => {
129
+ const refresh = deferred<GitState>();
130
+ let renderRequests = 0;
131
+
132
+ const widget = createFooterWidget(
133
+ {
134
+ cwd: "/repo",
135
+ sessionManager: {
136
+ getEntries: () => [],
137
+ },
138
+ } as unknown as ExtensionContext,
139
+ {
140
+ requestRender: () => {
141
+ renderRequests++;
142
+ },
143
+ },
144
+ {
145
+ onBranchChange: () => () => {},
146
+ },
147
+ {
148
+ fetchGitStatus: () => refresh.promise,
149
+ watchGitDir: () => () => {},
150
+ renderFooterLine: () => "",
151
+ getTotalCost: () => 0,
152
+ }
153
+ );
154
+
155
+ refresh.resolve({ branch: null, dirty: false, ahead: 0, behind: 0 });
156
+ await flushPromises();
157
+
158
+ expect(renderRequests).toBe(0);
159
+ widget.dispose();
160
+ });
161
+ });
@@ -0,0 +1,148 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ ReadonlyFooterDataProvider,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { Component, TUI } from "@earendil-works/pi-tui";
8
+ import { PimSettings } from "../../shared/PimSettings";
9
+ import { EMPTY_GIT, fetchGitStatus, type GitState, watchGitDir } from "./git";
10
+ import { renderFooterLine } from "./segments";
11
+
12
+ let activeGitRefresh: (() => void) | null = null;
13
+
14
+ type FooterTui = Pick<TUI, "requestRender">;
15
+ type FooterData = Pick<ReadonlyFooterDataProvider, "onBranchChange">;
16
+ type FooterWidget = Component & { readonly dispose: () => void };
17
+
18
+ type FooterWidgetDeps = {
19
+ readonly fetchGitStatus: (cwd: string) => Promise<GitState>;
20
+ readonly watchGitDir: (cwd: string, onChange: () => void) => () => void;
21
+ readonly renderFooterLine: (
22
+ width: number,
23
+ ctx: ExtensionContext,
24
+ gitState: GitState,
25
+ cost: number
26
+ ) => string;
27
+ readonly getTotalCost: (ctx: ExtensionContext) => number;
28
+ };
29
+
30
+ const DEFAULT_FOOTER_WIDGET_DEPS: FooterWidgetDeps = {
31
+ fetchGitStatus,
32
+ watchGitDir,
33
+ renderFooterLine,
34
+ getTotalCost,
35
+ };
36
+
37
+ export function getTotalCost(ctx: ExtensionContext): number {
38
+ let cost = 0;
39
+ for (const e of ctx.sessionManager.getEntries()) {
40
+ if (e.type === "message" && e.message.role === "assistant") {
41
+ cost += (e.message as AssistantMessage).usage.cost.total;
42
+ }
43
+ }
44
+ return cost;
45
+ }
46
+
47
+ export function createFooterWidget(
48
+ ctx: ExtensionContext,
49
+ tui: FooterTui,
50
+ footerData: FooterData,
51
+ deps: FooterWidgetDeps = DEFAULT_FOOTER_WIDGET_DEPS
52
+ ): FooterWidget {
53
+ let gitState: GitState = EMPTY_GIT;
54
+ let inFlight = false;
55
+ let pending = false;
56
+ const refresh = async (): Promise<void> => {
57
+ if (inFlight) {
58
+ pending = true;
59
+ return;
60
+ }
61
+ inFlight = true;
62
+ try {
63
+ do {
64
+ pending = false;
65
+ const next = await deps.fetchGitStatus(ctx.cwd);
66
+ if (
67
+ next.branch !== gitState.branch ||
68
+ next.dirty !== gitState.dirty ||
69
+ next.ahead !== gitState.ahead ||
70
+ next.behind !== gitState.behind
71
+ ) {
72
+ gitState = next;
73
+ tui.requestRender();
74
+ }
75
+ } while (pending);
76
+ } finally {
77
+ inFlight = false;
78
+ }
79
+ };
80
+ void refresh();
81
+ const unsubBranch = footerData.onBranchChange(() => {
82
+ void refresh();
83
+ });
84
+ const disposeGitWatch = deps.watchGitDir(ctx.cwd, () => {
85
+ void refresh();
86
+ });
87
+ activeGitRefresh = () => {
88
+ void refresh();
89
+ };
90
+ return {
91
+ invalidate(): void {},
92
+ render(width: number): string[] {
93
+ return [
94
+ deps.renderFooterLine(width, ctx, gitState, deps.getTotalCost(ctx)),
95
+ ];
96
+ },
97
+ dispose(): void {
98
+ unsubBranch();
99
+ disposeGitWatch();
100
+ activeGitRefresh = null;
101
+ },
102
+ };
103
+ }
104
+
105
+ function installFooter(ctx: ExtensionContext): void {
106
+ if (!ctx.hasUI) {
107
+ return;
108
+ }
109
+ ctx.ui.setFooter((tui, _theme, footerData) => {
110
+ return createFooterWidget(ctx, tui, footerData);
111
+ });
112
+ }
113
+
114
+ export default function (pi: ExtensionAPI): void {
115
+ const apply = async (ctx: ExtensionContext): Promise<void> => {
116
+ if (!ctx.hasUI) {
117
+ return;
118
+ }
119
+ const { enabled } = await PimSettings.get("powerline");
120
+ if (enabled) {
121
+ installFooter(ctx);
122
+ } else {
123
+ ctx.ui.setFooter(undefined);
124
+ }
125
+ };
126
+
127
+ pi.registerCommand("powerline", {
128
+ description: "Toggle Pim powerline footer",
129
+ handler: async (_args, ctx) => {
130
+ const current = await PimSettings.get("powerline");
131
+ const next = { ...current, enabled: !current.enabled };
132
+ await PimSettings.set("powerline", next);
133
+ await apply(ctx);
134
+ ctx.ui.notify(
135
+ `Pim powerline footer ${next.enabled ? "enabled" : "disabled"}`,
136
+ "info"
137
+ );
138
+ },
139
+ });
140
+
141
+ pi.on("session_start", async (_event, ctx) => {
142
+ await apply(ctx);
143
+ });
144
+
145
+ pi.on("tool_execution_end", () => {
146
+ activeGitRefresh?.();
147
+ });
148
+ }
@@ -0,0 +1,87 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ export const SEP_RIGHT = "";
4
+ export const SEP_LEFT = "";
5
+ export const SEP_THIN_LEFT = "";
6
+
7
+ export const GIT_ICON = "";
8
+ export const GIT_DIRTY_ICON = "";
9
+ export const GIT_AHEAD_ICON = "";
10
+ export const GIT_BEHIND_ICON = "";
11
+
12
+ export const RESET = "\x1b[0m";
13
+ export const BG_DEFAULT = "\x1b[49m";
14
+ export const REVERSE = "\x1b[7m";
15
+ export const RESET_REVERSE = "\x1b[27m";
16
+
17
+ export const FG_BLACK = "\x1b[30m";
18
+ export const FG_WHITE = "\x1b[97m";
19
+ export const BG_GRAY = "\x1b[100m";
20
+ export const BG_BRIGHT_RED = "\x1b[101m";
21
+ export const BG_BRIGHT_GREEN = "\x1b[102m";
22
+ export const BG_BRIGHT_YELLOW = "\x1b[103m";
23
+ export const BG_BRIGHT_MAGENTA = "\x1b[105m";
24
+
25
+ export const BG_TO_FG: Record<string, string> = {
26
+ [BG_GRAY]: "\x1b[90m",
27
+ [BG_BRIGHT_RED]: "\x1b[91m",
28
+ [BG_BRIGHT_GREEN]: "\x1b[92m",
29
+ [BG_BRIGHT_YELLOW]: "\x1b[93m",
30
+ [BG_BRIGHT_MAGENTA]: "\x1b[95m",
31
+ };
32
+
33
+ export type Segment = {
34
+ readonly text: string;
35
+ readonly fg: string;
36
+ readonly bg: string;
37
+ };
38
+
39
+ export function paint(seg: Segment): string {
40
+ return `${seg.bg}${seg.fg} ${seg.text} ${RESET}`;
41
+ }
42
+
43
+ export function chevronRight(prev: Segment, next: Segment | null): string {
44
+ const fg = BG_TO_FG[prev.bg]!;
45
+ if (next === null) {
46
+ return `${fg}${SEP_RIGHT}${RESET}`;
47
+ }
48
+ return `${fg}${next.bg}${SEP_RIGHT}${RESET}`;
49
+ }
50
+
51
+ export function chevronLeft(prev: Segment | null, next: Segment): string {
52
+ const fg = BG_TO_FG[next.bg]!;
53
+ if (prev === null) {
54
+ return `${fg}${SEP_LEFT}${RESET}`;
55
+ }
56
+ return `${fg}${prev.bg}${SEP_LEFT}${RESET}`;
57
+ }
58
+
59
+ export function thinChevronLeft(bg: string, fg: string): string {
60
+ return `${BG_DEFAULT}${BG_TO_FG[bg]}${REVERSE}${SEP_THIN_LEFT}${RESET_REVERSE}${bg}${fg}`;
61
+ }
62
+
63
+ export function renderLeftGroup(segs: readonly Segment[]): string {
64
+ let out = "";
65
+ for (let i = 0; i < segs.length; i++) {
66
+ out += paint(segs[i]!);
67
+ out += chevronRight(segs[i]!, segs[i + 1] ?? null);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ export function renderRightGroup(segs: readonly Segment[]): string {
73
+ let out = "";
74
+ for (let i = 0; i < segs.length; i++) {
75
+ out += chevronLeft(segs[i - 1] ?? null, segs[i]!);
76
+ out += paint(segs[i]!);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ export function groupWidth(segs: readonly Segment[]): number {
82
+ let w = 0;
83
+ for (const s of segs) {
84
+ w += visibleWidth(s.text) + 3;
85
+ }
86
+ return w;
87
+ }
@@ -0,0 +1,164 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { renderFooterLine } from "./segments";
5
+
6
+ function stripAnsi(s: string): string {
7
+ let out = "";
8
+ for (let i = 0; i < s.length; i++) {
9
+ if (s.charCodeAt(i) === 27 && s[i + 1] === "[") {
10
+ i += 2;
11
+ while (i < s.length && s[i] !== "m") {
12
+ i++;
13
+ }
14
+ } else {
15
+ out += s[i]!;
16
+ }
17
+ }
18
+ return out;
19
+ }
20
+
21
+ function createCtx(
22
+ branch: readonly unknown[] = [],
23
+ options: {
24
+ readonly cwd?: string;
25
+ readonly model?: { readonly id: string; readonly reasoning?: boolean };
26
+ } = {}
27
+ ): ExtensionContext {
28
+ return {
29
+ sessionManager: {
30
+ getCwd: () => options.cwd ?? "/home/aaroncql/dev/pim-agent",
31
+ getBranch: () => branch,
32
+ },
33
+ getContextUsage: () => ({
34
+ tokens: 200_000,
35
+ contextWindow: 200_000,
36
+ percent: 50,
37
+ }),
38
+ model: options.model ?? {
39
+ id: "gpt-5.5",
40
+ reasoning: true,
41
+ },
42
+ } as unknown as ExtensionContext;
43
+ }
44
+
45
+ describe("renderFooterLine", () => {
46
+ test("does not exceed narrow terminal widths", () => {
47
+ const ctx = createCtx();
48
+ const widths = [0, 1, 2, 3, 4, 8, 10, 12, 16, 20, 40];
49
+
50
+ for (const width of widths) {
51
+ const line = renderFooterLine(
52
+ width,
53
+ ctx,
54
+ {
55
+ branch: "feat/some-very-long-branch",
56
+ dirty: true,
57
+ ahead: 12,
58
+ behind: 3,
59
+ },
60
+ 12.34
61
+ );
62
+
63
+ expect(visibleWidth(line)).toBeLessThanOrEqual(width);
64
+ }
65
+ });
66
+
67
+ test("drops lower-priority segments as width tightens", () => {
68
+ const ctx = createCtx(
69
+ [{ type: "thinking_level_change", thinkingLevel: "medium" }],
70
+ { cwd: "/x/proj" }
71
+ );
72
+ const git = {
73
+ branch: "main",
74
+ dirty: true,
75
+ ahead: 2,
76
+ behind: 0,
77
+ };
78
+
79
+ expect(stripAnsi(renderFooterLine(200, ctx, git, 1.23))).toContain(
80
+ "gpt-5.5"
81
+ );
82
+ expect(stripAnsi(renderFooterLine(200, ctx, git, 1.23))).toContain("$1.23");
83
+ expect(stripAnsi(renderFooterLine(200, ctx, git, 1.23))).toContain("main");
84
+ expect(stripAnsi(renderFooterLine(200, ctx, git, 1.23))).toContain(
85
+ "50.0%/200K"
86
+ );
87
+
88
+ const withoutModel = stripAnsi(renderFooterLine(50, ctx, git, 1.23));
89
+ expect(withoutModel).not.toContain("gpt-5.5");
90
+ expect(withoutModel).toContain("$1.23");
91
+ expect(withoutModel).toContain("main");
92
+ expect(withoutModel).toContain("50.0%/200K");
93
+
94
+ const withoutCost = stripAnsi(renderFooterLine(40, ctx, git, 1.23));
95
+ expect(withoutCost).not.toContain("$1.23");
96
+ expect(withoutCost).toContain("main");
97
+ expect(withoutCost).toContain("50.0%/200K");
98
+
99
+ const withoutGit = stripAnsi(renderFooterLine(35, ctx, git, 1.23));
100
+ expect(withoutGit).not.toContain("main");
101
+ expect(withoutGit).toContain("/x/proj");
102
+ expect(withoutGit).toContain("50.0%/200K");
103
+
104
+ const cwdOnly = stripAnsi(renderFooterLine(20, ctx, git, 1.23));
105
+ expect(cwdOnly).toContain("/x/proj");
106
+ expect(cwdOnly).not.toContain("50.0%/200K");
107
+ });
108
+
109
+ test("renders latest reasoning level for reasoning models", () => {
110
+ const medium = stripAnsi(
111
+ renderFooterLine(
112
+ 120,
113
+ createCtx([{ type: "thinking_level_change", thinkingLevel: "medium" }]),
114
+ { branch: null, dirty: false, ahead: 0, behind: 0 },
115
+ 0
116
+ )
117
+ );
118
+ expect(medium).toContain("gpt-5.5");
119
+ expect(medium).toContain("med");
120
+
121
+ const latestWins = stripAnsi(
122
+ renderFooterLine(
123
+ 120,
124
+ createCtx([
125
+ { type: "thinking_level_change", thinkingLevel: "minimal" },
126
+ { type: "thinking_level_change", thinkingLevel: "xhigh" },
127
+ ]),
128
+ { branch: null, dirty: false, ahead: 0, behind: 0 },
129
+ 0
130
+ )
131
+ );
132
+ expect(latestWins).toContain("xhigh");
133
+ expect(latestWins).not.toContain("min");
134
+
135
+ const noLevel = stripAnsi(
136
+ renderFooterLine(
137
+ 120,
138
+ createCtx(),
139
+ { branch: null, dirty: false, ahead: 0, behind: 0 },
140
+ 0
141
+ )
142
+ );
143
+ expect(noLevel).toContain("off");
144
+ });
145
+
146
+ test("omits reasoning level for non-reasoning models", () => {
147
+ const line = stripAnsi(
148
+ renderFooterLine(
149
+ 120,
150
+ createCtx(
151
+ [{ type: "thinking_level_change", thinkingLevel: "medium" }],
152
+ {
153
+ model: { id: "gpt-5.5" },
154
+ }
155
+ ),
156
+ { branch: null, dirty: false, ahead: 0, behind: 0 },
157
+ 0
158
+ )
159
+ );
160
+
161
+ expect(line).toContain("gpt-5.5");
162
+ expect(line).not.toContain("med");
163
+ });
164
+ });