@shanepadgett/tau-agent 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/README.md +30 -0
- package/docs/extending-tau-agent.md +119 -0
- package/docs/subagents.md +57 -0
- package/docs/tui.md +107 -0
- package/extensions/appshot/README.md +11 -0
- package/extensions/appshot/capture.swift +191 -0
- package/extensions/appshot/index.ts +245 -0
- package/extensions/appshot/native-helper.ts +102 -0
- package/extensions/attention/README.md +23 -0
- package/extensions/attention/index.ts +71 -0
- package/extensions/auto-name/README.md +26 -0
- package/extensions/auto-name/index.ts +163 -0
- package/extensions/branch/README.md +22 -0
- package/extensions/branch/index.ts +145 -0
- package/extensions/branch/panel.ts +148 -0
- package/extensions/clear-screen/README.md +29 -0
- package/extensions/clear-screen/index.ts +75 -0
- package/extensions/commit/README.md +15 -0
- package/extensions/commit/commit-plan.ts +328 -0
- package/extensions/commit/git-change-set.ts +268 -0
- package/extensions/commit/index.ts +138 -0
- package/extensions/commit/review-ui.ts +745 -0
- package/extensions/explore/README.md +7 -0
- package/extensions/explore/autoread.ts +140 -0
- package/extensions/explore/find.ts +147 -0
- package/extensions/explore/grep.ts +749 -0
- package/extensions/explore/index.ts +17 -0
- package/extensions/explore/limits.ts +22 -0
- package/extensions/explore/ls.ts +113 -0
- package/extensions/explore/path-display.ts +41 -0
- package/extensions/explore/path-tree.ts +150 -0
- package/extensions/explore/read.ts +160 -0
- package/extensions/explore/result.ts +29 -0
- package/extensions/explore/traverse.ts +226 -0
- package/extensions/footer/README.md +23 -0
- package/extensions/footer/index.ts +534 -0
- package/extensions/footer/settings.ts +13 -0
- package/extensions/ideas/README.md +10 -0
- package/extensions/ideas/browser.ts +66 -0
- package/extensions/ideas/index.ts +31 -0
- package/extensions/ideas/store.ts +32 -0
- package/extensions/image-gen/README.md +11 -0
- package/extensions/image-gen/client.ts +186 -0
- package/extensions/image-gen/index.ts +152 -0
- package/extensions/manage-sessions/README.md +12 -0
- package/extensions/manage-sessions/index.ts +58 -0
- package/extensions/manage-sessions/manager-ui.ts +323 -0
- package/extensions/manage-sessions/sessions.ts +126 -0
- package/extensions/patch/README.md +48 -0
- package/extensions/patch/executor.ts +355 -0
- package/extensions/patch/index.ts +170 -0
- package/extensions/patch/matcher.ts +236 -0
- package/extensions/patch/parser.ts +347 -0
- package/extensions/patch/render.ts +247 -0
- package/extensions/patch/summary.ts +35 -0
- package/extensions/publish/README.md +11 -0
- package/extensions/publish/index.ts +225 -0
- package/extensions/qna/README.md +45 -0
- package/extensions/qna/additional-context-body.ts +51 -0
- package/extensions/qna/body-render.ts +6 -0
- package/extensions/qna/choice-question-body.ts +322 -0
- package/extensions/qna/index.ts +245 -0
- package/extensions/qna/inline-editor-row.ts +56 -0
- package/extensions/qna/input-question-body.ts +89 -0
- package/extensions/qna/model.ts +317 -0
- package/extensions/qna/panel.ts +240 -0
- package/extensions/qna/ui.ts +16 -0
- package/extensions/reference/README.md +47 -0
- package/extensions/reference/index.ts +76 -0
- package/extensions/reference/panel.ts +874 -0
- package/extensions/reference/settings.ts +29 -0
- package/extensions/run-summary/README.md +5 -0
- package/extensions/run-summary/index.ts +98 -0
- package/extensions/silent-command-runner/README.md +25 -0
- package/extensions/silent-command-runner/index.ts +468 -0
- package/extensions/silent-command-runner/settings.ts +65 -0
- package/extensions/soul/README.md +19 -0
- package/extensions/soul/index.ts +20 -0
- package/extensions/soul/prompt.ts +254 -0
- package/extensions/soul/settings.ts +13 -0
- package/extensions/stash/README.md +48 -0
- package/extensions/stash/browser.ts +51 -0
- package/extensions/stash/index.ts +55 -0
- package/extensions/stash/store.ts +36 -0
- package/extensions/subagent/README.md +34 -0
- package/extensions/subagent/agents/scout.md +85 -0
- package/extensions/subagent/agents/web-research.md +95 -0
- package/extensions/subagent/agents.ts +196 -0
- package/extensions/subagent/index.ts +214 -0
- package/extensions/subagent/render.ts +89 -0
- package/extensions/subagent/run.ts +325 -0
- package/extensions/tau/README.md +17 -0
- package/extensions/tau/index.ts +122 -0
- package/extensions/tau-help/README.md +3 -0
- package/extensions/tau-help/help.md +125 -0
- package/extensions/tau-help/index.ts +68 -0
- package/extensions/turn-budget/README.md +14 -0
- package/extensions/turn-budget/index.ts +150 -0
- package/extensions/turn-budget/settings.ts +35 -0
- package/extensions/web/README.md +13 -0
- package/extensions/web/codesearch.ts +79 -0
- package/extensions/web/exa.ts +101 -0
- package/extensions/web/html.ts +67 -0
- package/extensions/web/index.ts +13 -0
- package/extensions/web/limits.ts +11 -0
- package/extensions/web/tool-output.ts +54 -0
- package/extensions/web/webfetch.ts +177 -0
- package/extensions/web/websearch.ts +100 -0
- package/package.json +57 -0
- package/prompts/.gitkeep +1 -0
- package/prompts/cavemanify.md +25 -0
- package/prompts/implement.md +25 -0
- package/prompts/interview.md +48 -0
- package/prompts/plan-feature.md +30 -0
- package/prompts/plan-implementation.md +50 -0
- package/schemas/tau.schema.json +205 -0
- package/shared/agent-blocked.ts +12 -0
- package/shared/description.ts +28 -0
- package/shared/events.ts +135 -0
- package/shared/git.ts +41 -0
- package/shared/injected-context.ts +69 -0
- package/shared/jsonl-store.ts +108 -0
- package/shared/model-fallback/index.ts +278 -0
- package/shared/model-fallback/settings.ts +18 -0
- package/shared/model-fallback/types.ts +8 -0
- package/shared/ranges.ts +21 -0
- package/shared/settings/define.ts +15 -0
- package/shared/settings/files.ts +20 -0
- package/shared/settings/json.ts +41 -0
- package/shared/settings/load.ts +59 -0
- package/shared/settings/merge.ts +21 -0
- package/shared/settings/paths.ts +41 -0
- package/shared/settings/schema.ts +25 -0
- package/shared/settings/specs.ts +23 -0
- package/shared/text.ts +11 -0
- package/shared/tool-row-state.ts +43 -0
- package/skills/writing-preferences/SKILL.md +136 -0
- package/themes/.gitkeep +1 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
// lean: footer mixes git-status parse + usage/cost math + render; high churn
|
|
2
|
+
// (fallow hotspot). Do NOT proactive-split — wait for a change that forces a
|
|
3
|
+
// natural seam, then extract that concern only.
|
|
4
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join, relative } from "node:path";
|
|
7
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { onTauEvent } from "../../shared/events.ts";
|
|
11
|
+
import { loadTauExtensionSettings, updateTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
12
|
+
import footerSettings from "./settings.ts";
|
|
13
|
+
|
|
14
|
+
interface FooterItem {
|
|
15
|
+
id: string;
|
|
16
|
+
priority: number;
|
|
17
|
+
text: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface GitSummary {
|
|
21
|
+
branch: string;
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface UsageSummary {
|
|
26
|
+
input: number;
|
|
27
|
+
output: number;
|
|
28
|
+
cacheRead: number;
|
|
29
|
+
cacheWrite: number;
|
|
30
|
+
latestCacheHitRate: number | undefined;
|
|
31
|
+
cost: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const COMMAND = "footer";
|
|
35
|
+
const USAGE = "Usage: /footer [on|off|refresh]";
|
|
36
|
+
|
|
37
|
+
export default function footerExtension(pi: ExtensionAPI): void {
|
|
38
|
+
let enabled = true;
|
|
39
|
+
let activeCtx: ExtensionContext | undefined;
|
|
40
|
+
let footerInstalled = false;
|
|
41
|
+
let requestRender: (() => void) | undefined;
|
|
42
|
+
let unsubscribeFooterItems: (() => void) | undefined;
|
|
43
|
+
const gitByCwd = new Map<string, GitSummary | undefined>();
|
|
44
|
+
let gitRefresh: Promise<void> | undefined;
|
|
45
|
+
let dailyCost: number | undefined;
|
|
46
|
+
let dailyRefresh: Promise<void> | undefined;
|
|
47
|
+
const items = new Map<string, FooterItem>();
|
|
48
|
+
|
|
49
|
+
function render(): void {
|
|
50
|
+
requestRender?.();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function setActiveCtx(ctx: ExtensionContext): void {
|
|
54
|
+
activeCtx = ctx;
|
|
55
|
+
if (enabled && !footerInstalled) installFooter(ctx);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function installFooter(ctx: ExtensionContext): void {
|
|
59
|
+
footerInstalled = true;
|
|
60
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
61
|
+
requestRender = () => tui.requestRender();
|
|
62
|
+
const unsubscribeBranch = footerData.onBranchChange(() => {
|
|
63
|
+
void refreshGit(ctx).then(render);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
dispose() {
|
|
68
|
+
unsubscribeBranch();
|
|
69
|
+
if (requestRender) requestRender = undefined;
|
|
70
|
+
},
|
|
71
|
+
invalidate() {
|
|
72
|
+
render();
|
|
73
|
+
},
|
|
74
|
+
render(width: number): string[] {
|
|
75
|
+
if (!enabled) return [];
|
|
76
|
+
const currentCtx = activeCtx ?? ctx;
|
|
77
|
+
const git = gitByCwd.get(currentCtx.cwd);
|
|
78
|
+
const model = currentCtx.model ? `${currentCtx.model.provider}/${currentCtx.model.id}` : "no-model";
|
|
79
|
+
const thinking = pi.getThinkingLevel();
|
|
80
|
+
const topLeft = [gitText(git), `${model} (${thinking})`].filter(Boolean).join(" • ");
|
|
81
|
+
const sessionUsage = sessionCost(currentCtx);
|
|
82
|
+
const session = formatCost(sessionUsage.cost);
|
|
83
|
+
const daily = dailyCost === undefined ? "$?" : formatCost(dailyCost);
|
|
84
|
+
const topRight = statsText(theme, currentCtx, sessionUsage, session, daily);
|
|
85
|
+
const bottomLeft = [shortenPath(currentCtx.cwd), sessionName(currentCtx)].filter(Boolean).join(" • ");
|
|
86
|
+
const bottomRight = [extensionStatusesText(footerData.getExtensionStatuses()), footerItemsText(items)]
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join(" • ");
|
|
89
|
+
|
|
90
|
+
return [
|
|
91
|
+
renderSplit(width, theme.fg("dim", topLeft), topRight),
|
|
92
|
+
renderSplit(width, theme.fg("dim", bottomLeft), theme.fg("dim", bottomRight)),
|
|
93
|
+
];
|
|
94
|
+
},
|
|
95
|
+
} satisfies Component & { dispose(): void; invalidate(): void };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function refreshGit(ctx: ExtensionContext): Promise<void> {
|
|
100
|
+
if (gitRefresh) return gitRefresh;
|
|
101
|
+
const cwd = ctx.cwd;
|
|
102
|
+
gitRefresh = (async () => {
|
|
103
|
+
const result = await pi.exec(
|
|
104
|
+
"git",
|
|
105
|
+
["--no-optional-locks", "status", "--porcelain=v1", "--branch", "--untracked-files=normal"],
|
|
106
|
+
{ cwd, timeout: 1_000 },
|
|
107
|
+
);
|
|
108
|
+
gitByCwd.set(cwd, result.code === 0 ? parseGitStatus(result.stdout) : undefined);
|
|
109
|
+
})()
|
|
110
|
+
.catch(() => {
|
|
111
|
+
gitByCwd.set(cwd, undefined);
|
|
112
|
+
})
|
|
113
|
+
.finally(() => {
|
|
114
|
+
gitRefresh = undefined;
|
|
115
|
+
});
|
|
116
|
+
return gitRefresh;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function refreshDailyCost(): Promise<void> {
|
|
120
|
+
if (dailyRefresh) return dailyRefresh;
|
|
121
|
+
dailyRefresh = scanDailyCost()
|
|
122
|
+
.then((cost) => {
|
|
123
|
+
dailyCost = cost;
|
|
124
|
+
})
|
|
125
|
+
.catch(() => {})
|
|
126
|
+
.finally(() => {
|
|
127
|
+
dailyRefresh = undefined;
|
|
128
|
+
});
|
|
129
|
+
return dailyRefresh;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function refresh(ctx: ExtensionContext, includeDaily = true): void {
|
|
133
|
+
const tasks = includeDaily ? [refreshGit(ctx), refreshDailyCost()] : [refreshGit(ctx)];
|
|
134
|
+
void Promise.all(tasks).then(render);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function rerender(ctx: ExtensionContext): void {
|
|
138
|
+
setActiveCtx(ctx);
|
|
139
|
+
render();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function setEnabled(ctx: ExtensionContext, next: boolean): void {
|
|
143
|
+
enabled = next;
|
|
144
|
+
if (enabled) {
|
|
145
|
+
installFooter(ctx);
|
|
146
|
+
refresh(ctx);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
ctx.ui.setFooter(undefined);
|
|
150
|
+
footerInstalled = false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
unsubscribeFooterItems = onTauEvent(pi, "footer.items", "tau:footer-item", (item) => {
|
|
154
|
+
if (!item.id.trim()) return;
|
|
155
|
+
const text = item.text?.trim();
|
|
156
|
+
const existing = items.get(item.id);
|
|
157
|
+
if (!text) {
|
|
158
|
+
if (!existing) return;
|
|
159
|
+
items.delete(item.id);
|
|
160
|
+
render();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const next = { id: item.id, priority: item.priority ?? 0, text };
|
|
165
|
+
if (existing?.priority === next.priority && existing.text === next.text) return;
|
|
166
|
+
items.set(item.id, next);
|
|
167
|
+
render();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
pi.registerCommand(COMMAND, {
|
|
171
|
+
description: "Toggle Tau footer",
|
|
172
|
+
handler: async (args, ctx) => {
|
|
173
|
+
await ctx.waitForIdle();
|
|
174
|
+
setActiveCtx(ctx);
|
|
175
|
+
|
|
176
|
+
const arg = args.trim().toLowerCase();
|
|
177
|
+
if (!arg) {
|
|
178
|
+
const next = !enabled;
|
|
179
|
+
setEnabled(ctx, next);
|
|
180
|
+
await saveEnabled(ctx, next);
|
|
181
|
+
ctx.ui.notify(enabled ? "Footer enabled" : "Footer disabled", "info");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (arg === "on" || arg === "off") {
|
|
186
|
+
const next = arg === "on";
|
|
187
|
+
setEnabled(ctx, next);
|
|
188
|
+
await saveEnabled(ctx, next);
|
|
189
|
+
ctx.ui.notify(enabled ? "Footer enabled" : "Footer disabled", "info");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (arg === "refresh") {
|
|
194
|
+
refresh(ctx);
|
|
195
|
+
ctx.ui.notify("Footer refresh started", "info");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
ctx.ui.notify(USAGE, "error");
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
204
|
+
const settings = await loadTauExtensionSettings(ctx, footerSettings);
|
|
205
|
+
setEnabled(ctx, settings.enabled);
|
|
206
|
+
onStateChange(ctx);
|
|
207
|
+
});
|
|
208
|
+
pi.on("session_tree", (_event, ctx) => onStateChange(ctx, false));
|
|
209
|
+
pi.on("model_select", (_event, ctx) => rerender(ctx));
|
|
210
|
+
pi.on("thinking_level_select", (_event, ctx) => rerender(ctx));
|
|
211
|
+
pi.on("agent_start", (_event, ctx) => onStateChange(ctx, false));
|
|
212
|
+
pi.on("turn_end", (_event, ctx) => onStateChange(ctx));
|
|
213
|
+
pi.on("agent_end", (_event, ctx) => onStateChange(ctx));
|
|
214
|
+
|
|
215
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
216
|
+
unsubscribeFooterItems?.();
|
|
217
|
+
unsubscribeFooterItems = undefined;
|
|
218
|
+
requestRender = undefined;
|
|
219
|
+
activeCtx = undefined;
|
|
220
|
+
footerInstalled = false;
|
|
221
|
+
ctx.ui.setFooter(undefined);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function onStateChange(ctx: ExtensionContext, includeDaily = true): void {
|
|
225
|
+
setActiveCtx(ctx);
|
|
226
|
+
refresh(ctx, includeDaily);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function saveEnabled(ctx: ExtensionContext, enabled: boolean): Promise<void> {
|
|
231
|
+
await updateTauExtensionSettings("global", ctx, footerSettings, (current) => ({ ...current, enabled }));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseGitStatus(stdout: string): GitSummary | undefined {
|
|
235
|
+
let branch = "";
|
|
236
|
+
let staged = 0;
|
|
237
|
+
let modified = 0;
|
|
238
|
+
let untracked = 0;
|
|
239
|
+
let conflicts = 0;
|
|
240
|
+
|
|
241
|
+
for (const line of stdout.split("\n")) {
|
|
242
|
+
if (!line) continue;
|
|
243
|
+
if (line.startsWith("## ")) {
|
|
244
|
+
branch = line.slice(3).split("...")[0]?.trim() ?? "";
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const x = line[0];
|
|
249
|
+
const y = line[1];
|
|
250
|
+
if (x === "?" && y === "?") {
|
|
251
|
+
untracked += 1;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (isConflict(x, y)) {
|
|
255
|
+
conflicts += 1;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (x && x !== " ") staged += 1;
|
|
259
|
+
if (y && y !== " ") modified += 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!branch) return undefined;
|
|
263
|
+
const parts = [
|
|
264
|
+
staged ? `+${staged}` : "",
|
|
265
|
+
modified ? `~${modified}` : "",
|
|
266
|
+
untracked ? `?${untracked}` : "",
|
|
267
|
+
conflicts ? `!${conflicts}` : "",
|
|
268
|
+
].filter(Boolean);
|
|
269
|
+
return { branch, text: parts.length ? parts.join(" ") : "clean" };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function gitText(git: GitSummary | undefined): string {
|
|
273
|
+
if (!git) return "";
|
|
274
|
+
return git.text === "clean" ? git.branch : `${git.branch} (${git.text})`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isConflict(x: string | undefined, y: string | undefined): boolean {
|
|
278
|
+
return x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sessionCost(ctx: ExtensionContext): UsageSummary {
|
|
282
|
+
let usage = zeroUsage();
|
|
283
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
284
|
+
if (entry.type !== "message") continue;
|
|
285
|
+
if (entry.message.role === "assistant") {
|
|
286
|
+
usage = addUsage(usage, normalizeUsage((entry.message as AssistantMessage).usage));
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (entry.message.role !== "toolResult" || entry.message.toolName !== "subagent") continue;
|
|
290
|
+
const childUsage = subagentUsage(entry.message.details);
|
|
291
|
+
if (childUsage) usage = addUsage(usage, childUsage);
|
|
292
|
+
}
|
|
293
|
+
return usage;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function statsText(theme: Theme, ctx: ExtensionContext, usage: UsageSummary, session: string, daily: string): string {
|
|
297
|
+
const tokens = [
|
|
298
|
+
`↑${formatTokens(usage.input)}`,
|
|
299
|
+
`↓${formatTokens(usage.output)}`,
|
|
300
|
+
`R${formatTokens(usage.cacheRead)}`,
|
|
301
|
+
`W${formatTokens(usage.cacheWrite)}`,
|
|
302
|
+
];
|
|
303
|
+
if ((usage.cacheRead || usage.cacheWrite) && usage.latestCacheHitRate !== undefined)
|
|
304
|
+
tokens.push(`CH${usage.latestCacheHitRate.toFixed(1)}%`);
|
|
305
|
+
return [contextText(theme, ctx), theme.fg("dim", tokens.join(" ")), theme.fg("dim", `S|${session} D|${daily}`)]
|
|
306
|
+
.filter(Boolean)
|
|
307
|
+
.join(theme.fg("dim", " • "));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function contextText(theme: Theme, ctx: ExtensionContext): string {
|
|
311
|
+
const usage = ctx.getContextUsage();
|
|
312
|
+
const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
313
|
+
const percentValue = typeof usage?.percent === "number" ? usage.percent : undefined;
|
|
314
|
+
const percent = percentValue === undefined ? "?" : `${percentValue.toFixed(1)}%`;
|
|
315
|
+
return `${theme.fg(contextPercentColor(percentValue), percent)}${theme.fg("dim", `/${formatTokens(contextWindow)}`)}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function contextPercentColor(percent: number | undefined): ThemeColor {
|
|
319
|
+
if (percent === undefined) return "dim";
|
|
320
|
+
if (percent > 80) return "error";
|
|
321
|
+
if (percent > 50) return "warning";
|
|
322
|
+
return "dim";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function formatTokens(count: number): string {
|
|
326
|
+
if (count < 1000) return `${Math.round(count)}`;
|
|
327
|
+
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
|
|
328
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
329
|
+
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
330
|
+
return `${Math.round(count / 1_000_000)}M`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function scanDailyCost(): Promise<number> {
|
|
334
|
+
const range = localDayRange(Date.now());
|
|
335
|
+
const seen = new Set<string>();
|
|
336
|
+
let total = 0;
|
|
337
|
+
|
|
338
|
+
for (const path of await sessionFiles()) {
|
|
339
|
+
try {
|
|
340
|
+
if ((await stat(path)).mtimeMs < range.startMs) continue;
|
|
341
|
+
const raw = await readFile(path, "utf8");
|
|
342
|
+
for (const line of raw.split("\n")) {
|
|
343
|
+
const entry = parseRecord(line);
|
|
344
|
+
if (!entry || entry.type !== "message") continue;
|
|
345
|
+
const message = asRecord(entry.message);
|
|
346
|
+
if (!message) continue;
|
|
347
|
+
const timestamp = timestampMs(message.timestamp, entry.timestamp);
|
|
348
|
+
if (timestamp < range.startMs || timestamp >= range.endMs) continue;
|
|
349
|
+
let usage: UsageSummary;
|
|
350
|
+
let identity: unknown[];
|
|
351
|
+
if (message.role === "assistant") {
|
|
352
|
+
usage = normalizeUsage(message.usage);
|
|
353
|
+
identity = [message.provider, message.model];
|
|
354
|
+
} else if (message.role === "toolResult" && message.toolName === "subagent") {
|
|
355
|
+
const childUsage = subagentUsage(message.details);
|
|
356
|
+
if (!childUsage) continue;
|
|
357
|
+
usage = childUsage;
|
|
358
|
+
identity = ["subagent", message.toolCallId];
|
|
359
|
+
} else continue;
|
|
360
|
+
const key = [
|
|
361
|
+
...identity,
|
|
362
|
+
timestamp,
|
|
363
|
+
usage.input,
|
|
364
|
+
usage.output,
|
|
365
|
+
usage.cacheRead,
|
|
366
|
+
usage.cacheWrite,
|
|
367
|
+
usage.cost,
|
|
368
|
+
].join(":");
|
|
369
|
+
if (seen.has(key)) continue;
|
|
370
|
+
seen.add(key);
|
|
371
|
+
total += usage.cost;
|
|
372
|
+
}
|
|
373
|
+
} catch {
|
|
374
|
+
// One bad session file should not break the footer.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return total;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function sessionFiles(): Promise<string[]> {
|
|
382
|
+
const root = join(homedir(), ".pi", "agent", "sessions");
|
|
383
|
+
const files: string[] = [];
|
|
384
|
+
try {
|
|
385
|
+
for (const dir of await readdir(root, { withFileTypes: true })) {
|
|
386
|
+
if (!dir.isDirectory()) continue;
|
|
387
|
+
const child = join(root, dir.name);
|
|
388
|
+
for (const file of await readdir(child, { withFileTypes: true })) {
|
|
389
|
+
if (file.isFile() && file.name.endsWith(".jsonl")) files.push(join(child, file.name));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
} catch {
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
return files;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function parseRecord(line: string): Record<string, unknown> | undefined {
|
|
399
|
+
const trimmed = line.trim();
|
|
400
|
+
if (!trimmed) return undefined;
|
|
401
|
+
try {
|
|
402
|
+
return asRecord(JSON.parse(trimmed));
|
|
403
|
+
} catch {
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function normalizeUsage(value: unknown): UsageSummary {
|
|
409
|
+
const record = asRecord(value);
|
|
410
|
+
const cost = asRecord(record?.cost);
|
|
411
|
+
const input = numberOrZero(record?.input);
|
|
412
|
+
const output = numberOrZero(record?.output);
|
|
413
|
+
const cacheRead = numberOrZero(record?.cacheRead);
|
|
414
|
+
const cacheWrite = numberOrZero(record?.cacheWrite);
|
|
415
|
+
const promptTokens = input + cacheRead + cacheWrite;
|
|
416
|
+
const costTotal =
|
|
417
|
+
numberOrZero(cost?.total) ||
|
|
418
|
+
numberOrZero(cost?.input) +
|
|
419
|
+
numberOrZero(cost?.output) +
|
|
420
|
+
numberOrZero(cost?.cacheRead) +
|
|
421
|
+
numberOrZero(cost?.cacheWrite);
|
|
422
|
+
return {
|
|
423
|
+
input,
|
|
424
|
+
output,
|
|
425
|
+
cacheRead,
|
|
426
|
+
cacheWrite,
|
|
427
|
+
latestCacheHitRate: promptTokens > 0 ? (cacheRead / promptTokens) * 100 : undefined,
|
|
428
|
+
cost: costTotal,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function subagentUsage(details: unknown): UsageSummary | undefined {
|
|
433
|
+
const usage = asRecord(asRecord(details)?.usage);
|
|
434
|
+
if (!usage) return undefined;
|
|
435
|
+
const input = numberOrZero(usage.input);
|
|
436
|
+
const output = numberOrZero(usage.output);
|
|
437
|
+
const cacheRead = numberOrZero(usage.cacheRead);
|
|
438
|
+
const cacheWrite = numberOrZero(usage.cacheWrite);
|
|
439
|
+
const promptTokens = input + cacheRead + cacheWrite;
|
|
440
|
+
return {
|
|
441
|
+
input,
|
|
442
|
+
output,
|
|
443
|
+
cacheRead,
|
|
444
|
+
cacheWrite,
|
|
445
|
+
latestCacheHitRate: promptTokens > 0 ? (cacheRead / promptTokens) * 100 : undefined,
|
|
446
|
+
cost: numberOrZero(usage.cost),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function addUsage(left: UsageSummary, right: UsageSummary): UsageSummary {
|
|
451
|
+
return {
|
|
452
|
+
input: left.input + right.input,
|
|
453
|
+
output: left.output + right.output,
|
|
454
|
+
cacheRead: left.cacheRead + right.cacheRead,
|
|
455
|
+
cacheWrite: left.cacheWrite + right.cacheWrite,
|
|
456
|
+
latestCacheHitRate: right.latestCacheHitRate ?? left.latestCacheHitRate,
|
|
457
|
+
cost: left.cost + right.cost,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function zeroUsage(): UsageSummary {
|
|
462
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, latestCacheHitRate: undefined, cost: 0 };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function timestampMs(messageTimestamp: unknown, entryTimestamp: unknown): number {
|
|
466
|
+
if (typeof messageTimestamp === "number") return messageTimestamp;
|
|
467
|
+
if (typeof entryTimestamp === "string") return Date.parse(entryTimestamp);
|
|
468
|
+
return Number.NaN;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function localDayRange(nowMs: number): { endMs: number; startMs: number } {
|
|
472
|
+
const start = new Date(nowMs);
|
|
473
|
+
start.setHours(0, 0, 0, 0);
|
|
474
|
+
const end = new Date(start);
|
|
475
|
+
end.setDate(end.getDate() + 1);
|
|
476
|
+
return { startMs: start.getTime(), endMs: end.getTime() };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function footerItemsText(items: ReadonlyMap<string, FooterItem>): string {
|
|
480
|
+
return [...items.values()]
|
|
481
|
+
.sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id))
|
|
482
|
+
.map((item) => item.text)
|
|
483
|
+
.join(" • ");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function extensionStatusesText(statuses: ReadonlyMap<string, string>): string {
|
|
487
|
+
return [...statuses]
|
|
488
|
+
.filter(([, text]) => text.trim())
|
|
489
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
490
|
+
.map(([, text]) => text.trim())
|
|
491
|
+
.join(" • ");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function renderSplit(width: number, left: string, right: string): string {
|
|
495
|
+
if (width <= 0) return "";
|
|
496
|
+
const rightWidth = visibleWidth(right);
|
|
497
|
+
if (!right) return truncateToWidth(left, width);
|
|
498
|
+
if (!left) return truncateToWidth(right, width);
|
|
499
|
+
const leftWidth = Math.max(0, width - rightWidth - 1);
|
|
500
|
+
if (leftWidth <= 0) return truncateToWidth(right, width);
|
|
501
|
+
const clippedLeft = truncateToWidth(left, leftWidth);
|
|
502
|
+
const pad = " ".repeat(Math.max(1, width - visibleWidth(clippedLeft) - rightWidth));
|
|
503
|
+
return truncateToWidth(`${clippedLeft}${pad}${right}`, width);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function shortenPath(cwd: string): string {
|
|
507
|
+
const home = homedir();
|
|
508
|
+
if (cwd === home) return "~";
|
|
509
|
+
if (cwd.startsWith(`${home}/`)) return `~/${relative(home, cwd)}`;
|
|
510
|
+
return cwd;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function sessionName(ctx: ExtensionContext): string {
|
|
514
|
+
const entries = ctx.sessionManager.getEntries();
|
|
515
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
516
|
+
const entry = entries[index];
|
|
517
|
+
if (entry?.type !== "session_info") continue;
|
|
518
|
+
const name = entry.name?.trim();
|
|
519
|
+
if (name) return name;
|
|
520
|
+
}
|
|
521
|
+
return "";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function formatCost(value: number): string {
|
|
525
|
+
return `$${value.toFixed(2)}`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
529
|
+
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function numberOrZero(value: unknown): number {
|
|
533
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
534
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineTauExtensionSettings } from "../../shared/settings/define.ts";
|
|
3
|
+
|
|
4
|
+
export default defineTauExtensionSettings({
|
|
5
|
+
key: "footer",
|
|
6
|
+
defaults: { enabled: true as boolean },
|
|
7
|
+
schema: Type.Object(
|
|
8
|
+
{
|
|
9
|
+
enabled: Type.Optional(Type.Boolean({ default: true, description: "Enable Tau's compact footer." })),
|
|
10
|
+
},
|
|
11
|
+
{ additionalProperties: false },
|
|
12
|
+
),
|
|
13
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Ideas
|
|
2
|
+
|
|
3
|
+
Capture rough, unrefined ideas for the current repository without turning them
|
|
4
|
+
into plans or committed docs too early.
|
|
5
|
+
|
|
6
|
+
Use `/ideas <text>` to park a thought. Use `/ideas` in the TUI to browse, edit,
|
|
7
|
+
delete, or load an idea back into the prompt when it is ready to shape into a
|
|
8
|
+
request.
|
|
9
|
+
|
|
10
|
+
Rough ideas are not plans. This is the parking lot.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
3
|
+
import { rawHint } from "@shanepadgett/tau-tui";
|
|
4
|
+
import {
|
|
5
|
+
createTextRecordSelectPanel,
|
|
6
|
+
type TextRecordSelectPanelConfig,
|
|
7
|
+
type TextRecordSelectResult,
|
|
8
|
+
} from "@shanepadgett/tau-tui";
|
|
9
|
+
import { deleteIdea, type Idea, ideasFilePath, loadIdeas, updateIdea } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
const CONFIG: Omit<TextRecordSelectPanelConfig, "path"> = {
|
|
12
|
+
title: "Ideas",
|
|
13
|
+
emptyMessage: "No ideas yet. Use /ideas <text> to log one.",
|
|
14
|
+
primaryLabel: "insert",
|
|
15
|
+
expandActiveItem: true,
|
|
16
|
+
actions: [
|
|
17
|
+
{ id: "edit", key: Key.ctrl("e"), hint: rawHint("ctrl+e", "edit") },
|
|
18
|
+
{ id: "delete", key: Key.ctrl("d"), hint: rawHint("ctrl+d", "delete") },
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function browseIdeas(ctx: ExtensionCommandContext): Promise<Idea | undefined> {
|
|
23
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) {
|
|
24
|
+
ctx.ui.notify("Ideas browser requires TUI mode.", "error");
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const path = await ideasFilePath(ctx.cwd);
|
|
29
|
+
|
|
30
|
+
while (true) {
|
|
31
|
+
const ideas = await loadIdeas(ctx.cwd);
|
|
32
|
+
const result = await show(ctx, ideas, path);
|
|
33
|
+
|
|
34
|
+
if (result.kind === "cancel") return undefined;
|
|
35
|
+
if (result.kind === "primary") return result.item;
|
|
36
|
+
|
|
37
|
+
if (result.actionId === "delete") {
|
|
38
|
+
const ok = await ctx.ui.confirm("Delete idea?", result.item.text);
|
|
39
|
+
if (ok) {
|
|
40
|
+
await deleteIdea(ctx.cwd, result.item.id);
|
|
41
|
+
ctx.ui.notify("Idea deleted.", "info");
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// edit: native multiline editor, prefilled with the current text.
|
|
47
|
+
const edited = await ctx.ui.editor("Edit idea", result.item.text);
|
|
48
|
+
if (edited == null) continue;
|
|
49
|
+
if (!edited.trim()) {
|
|
50
|
+
ctx.ui.notify("Edit cancelled (empty).", "info");
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
await updateIdea(ctx.cwd, result.item.id, edited);
|
|
54
|
+
ctx.ui.notify("Idea updated.", "info");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function show(
|
|
59
|
+
ctx: ExtensionCommandContext,
|
|
60
|
+
ideas: readonly Idea[],
|
|
61
|
+
path: string,
|
|
62
|
+
): Promise<TextRecordSelectResult<Idea>> {
|
|
63
|
+
return ctx.ui.custom<TextRecordSelectResult<Idea>>((_tui, theme, _keybindings, done) =>
|
|
64
|
+
createTextRecordSelectPanel(theme, ideas, { ...CONFIG, path }, done),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { preview } from "../../shared/text.ts";
|
|
3
|
+
import { browseIdeas } from "./browser.ts";
|
|
4
|
+
import { addIdea } from "./store.ts";
|
|
5
|
+
|
|
6
|
+
export default function ideasExtension(pi: ExtensionAPI): void {
|
|
7
|
+
pi.registerCommand("ideas", {
|
|
8
|
+
description: "Log a rough idea for this repo, or browse ideas with no args",
|
|
9
|
+
handler: async (args, ctx) => {
|
|
10
|
+
await ctx.waitForIdle();
|
|
11
|
+
|
|
12
|
+
const text = args.trim();
|
|
13
|
+
if (text) {
|
|
14
|
+
const idea = await addIdea(ctx.cwd, text);
|
|
15
|
+
ctx.ui.notify(`Logged idea: ${preview(idea.text, 60)}`, "info");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (ctx.mode !== "tui") {
|
|
20
|
+
ctx.ui.notify("Use /ideas <text> to log, or run in TUI to browse.", "info");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const idea = await browseIdeas(ctx);
|
|
25
|
+
if (idea) {
|
|
26
|
+
ctx.ui.setEditorText(idea.text);
|
|
27
|
+
ctx.ui.notify("Idea loaded into the editor — edit and submit.", "info");
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { appendRecord, type DatedRecord, loadRecords, recordFilePath, saveRecords } from "../../shared/jsonl-store.ts";
|
|
2
|
+
|
|
3
|
+
export type Idea = DatedRecord;
|
|
4
|
+
|
|
5
|
+
const IDEAS_FILENAME = "ideas.jsonl";
|
|
6
|
+
const LABEL = "idea";
|
|
7
|
+
|
|
8
|
+
export function ideasFilePath(cwd: string): Promise<string> {
|
|
9
|
+
return recordFilePath(cwd, IDEAS_FILENAME);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function loadIdeas(cwd: string): Promise<Idea[]> {
|
|
13
|
+
return loadRecords(cwd, IDEAS_FILENAME, LABEL);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function addIdea(cwd: string, text: string): Promise<Idea> {
|
|
17
|
+
return appendRecord(cwd, IDEAS_FILENAME, text);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function updateIdea(cwd: string, id: string, text: string): Promise<Idea[]> {
|
|
21
|
+
const ideas = await loadIdeas(cwd);
|
|
22
|
+
const next = ideas.map((idea) => (idea.id === id ? { ...idea, text: text.trim() } : idea));
|
|
23
|
+
await saveRecords(cwd, IDEAS_FILENAME, next);
|
|
24
|
+
return next;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function deleteIdea(cwd: string, id: string): Promise<Idea[]> {
|
|
28
|
+
const ideas = await loadIdeas(cwd);
|
|
29
|
+
const next = ideas.filter((idea) => idea.id !== id);
|
|
30
|
+
await saveRecords(cwd, IDEAS_FILENAME, next);
|
|
31
|
+
return next;
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Image Generation
|
|
2
|
+
|
|
3
|
+
`image_gen` generates raster images and edits up to five local raster images with OpenAI Codex. It uses the current `gpt-image-2` model and saves results under `~/.local/share/tau-agent/images/` by default. Pass an explicit PNG path when the image should be saved in the current repository or another chosen location.
|
|
4
|
+
|
|
5
|
+
Use `/login` and select OpenAI Codex before invoking the tool. No `OPENAI_API_KEY` is needed.
|
|
6
|
+
|
|
7
|
+
Run `/reload` after installing or changing the extension.
|
|
8
|
+
|
|
9
|
+
The model invokes `image_gen` with a prompt. Size, quality, and background use the Codex automatic settings. For edits, it also supplies one to five local PNG, JPEG, or WebP paths. Successful PNGs up to 12 MiB are returned inline for inspection; larger results remain available at the saved path.
|
|
10
|
+
|
|
11
|
+
This extension calls a private Codex backend. OpenAI may change its availability or protocol without notice.
|