@yagni-app/code 1.0.0 → 1.0.2
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 +42 -0
- package/dist/cli.js +231 -6
- package/dist/crashReport.d.ts +8 -0
- package/dist/crashReport.js +13 -1
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +33 -0
- package/dist/extension/askAdvisorTool.d.ts +14 -0
- package/dist/extension/askAdvisorTool.js +16 -4
- package/dist/extension/askYagniTool.d.ts +1 -1
- package/dist/extension/askYagniTool.js +21 -0
- package/dist/extension/branding.d.ts +15 -0
- package/dist/extension/branding.js +76 -0
- package/dist/extension/childUsage.d.ts +40 -0
- package/dist/extension/childUsage.js +43 -0
- package/dist/extension/chipEditor.d.ts +22 -1
- package/dist/extension/chipEditor.js +58 -5
- package/dist/extension/condensedTools.d.ts +97 -0
- package/dist/extension/condensedTools.js +396 -0
- package/dist/extension/diffStat.d.ts +62 -0
- package/dist/extension/diffStat.js +158 -0
- package/dist/extension/footer.d.ts +15 -1
- package/dist/extension/footer.js +35 -15
- package/dist/extension/index.d.ts +6 -0
- package/dist/extension/index.js +126 -6
- package/dist/extension/permission/execPolicy.js +47 -0
- package/dist/extension/permission/gate.d.ts +6 -0
- package/dist/extension/permission/gate.js +11 -2
- package/dist/extension/permission/guardian.d.ts +20 -0
- package/dist/extension/permission/guardian.js +16 -1
- package/dist/extension/pipeline/goCommand.d.ts +8 -0
- package/dist/extension/pipeline/goCommand.js +8 -0
- package/dist/extension/pipeline/invocation.d.ts +7 -0
- package/dist/extension/pipeline/invocation.js +7 -0
- package/dist/extension/pipeline/personas.js +4 -4
- package/dist/extension/pipeline/runner.d.ts +1 -0
- package/dist/extension/pipeline/runner.js +15 -3
- package/dist/extension/pipeline/sessionWorktree.d.ts +64 -0
- package/dist/extension/pipeline/sessionWorktree.js +225 -0
- package/dist/extension/scratchpad.d.ts +66 -0
- package/dist/extension/scratchpad.js +93 -0
- package/dist/extension/slashCommandFilter.d.ts +30 -0
- package/dist/extension/slashCommandFilter.js +89 -0
- package/dist/extension/subagents.d.ts +21 -1
- package/dist/extension/subagents.js +34 -5
- package/dist/extension/todos.d.ts +1 -0
- package/dist/extension/todos.js +15 -0
- package/dist/extension/toolRuns.d.ts +92 -0
- package/dist/extension/toolRuns.js +201 -0
- package/dist/extension/webFetchTool.js +2 -0
- package/dist/extension/workingLine.d.ts +49 -0
- package/dist/extension/workingLine.js +116 -0
- package/dist/feedback.d.ts +77 -0
- package/dist/feedback.js +500 -0
- package/dist/goHeadless.d.ts +3 -0
- package/dist/goHeadless.js +13 -0
- package/dist/launch.d.ts +8 -0
- package/dist/launch.js +6 -0
- package/dist/otel.d.ts +150 -0
- package/dist/otel.js +291 -0
- package/dist/outputFormat.d.ts +83 -0
- package/dist/outputFormat.js +207 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +13 -0
- package/dist/worktreeArgs.d.ts +43 -0
- package/dist/worktreeArgs.js +96 -0
- package/package.json +3 -2
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Condensed, Claude Code-style rendering for pi's seven built-in tools.
|
|
3
|
+
*
|
|
4
|
+
* pi resolves renderers per slot (`toolDefinition.renderCall ?? builtIn.renderCall`),
|
|
5
|
+
* so re-registering a built-in by name with the SAME factory-made definition
|
|
6
|
+
* spread underneath swaps ONLY the presentation: name, description, parameters,
|
|
7
|
+
* prompt metadata, and execution semantics stay byte-identical with the
|
|
8
|
+
* built-in (execution delegates to the same `create*ToolDefinition` the session
|
|
9
|
+
* would have built, including the settings-driven bash shellPath/commandPrefix
|
|
10
|
+
* and read image auto-resize). pi 0.84.1 registers such overrides silently —
|
|
11
|
+
* there is no built-in-override startup warning in this version (verified
|
|
12
|
+
* against dist; the extensions.md claim is stale).
|
|
13
|
+
*
|
|
14
|
+
* Rendering contract per row (renderShell "self", so no tinted Box):
|
|
15
|
+
* - renderCall paints the TITLE slot: a live "● Tool(arg)" line while running,
|
|
16
|
+
* a "● Tool(arg)" line for visible rows, the run summary line for the tail
|
|
17
|
+
* of a completed quiet run, or nothing (a zero-line component removes the
|
|
18
|
+
* row entirely, including its spacer).
|
|
19
|
+
* - renderResult paints the BODY slot: condensed write/edit previews, bash
|
|
20
|
+
* error/partial output tails, or nothing. Expanded (ctrl+o) shows full
|
|
21
|
+
* output for every row.
|
|
22
|
+
*
|
|
23
|
+
* String assembly is pure over {@link RenderTheme} (subagentRender.ts's
|
|
24
|
+
* pattern) so tests run against plain text. Renderer exceptions are swallowed
|
|
25
|
+
* by pi (degrading to the built-in fallback), so everything stays total.
|
|
26
|
+
*/
|
|
27
|
+
import * as os from "node:os";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import { createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, getAgentDir, getLanguageFromPath, highlightCode, renderDiff, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
31
|
+
import { isQuiet, kindForTool, ToolRunTracker } from "./toolRuns.js";
|
|
32
|
+
/** Lines of write content shown collapsed (mirrors Claude Code's preview). */
|
|
33
|
+
export const WRITE_PREVIEW_LINES = 8;
|
|
34
|
+
/** Visual lines of a diff shown collapsed. */
|
|
35
|
+
export const DIFF_PREVIEW_LINES = 12;
|
|
36
|
+
/** Output tail lines shown under a failed shell command. */
|
|
37
|
+
export const ERROR_TAIL_LINES = 5;
|
|
38
|
+
/** Output tail lines shown under a still-running shell command. */
|
|
39
|
+
export const PARTIAL_TAIL_LINES = 3;
|
|
40
|
+
/** Hard cap on expanded body lines so a huge output cannot stall a repaint. */
|
|
41
|
+
const EXPANDED_MAX_LINES = 1000;
|
|
42
|
+
/** Title arg preview width. */
|
|
43
|
+
const ARG_PREVIEW_MAX = 96;
|
|
44
|
+
/** Left/right margin for self-rendered rows, matching pi's `outputPad` (1) so
|
|
45
|
+
* tool rows align with prose. Self-render rows bypass pi's pad (renderShell
|
|
46
|
+
* "self" → unpadded container), so we reapply it here. */
|
|
47
|
+
export const OUTPUT_PAD = 1;
|
|
48
|
+
const BUILTIN_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
49
|
+
const TITLE_BY_NAME = {
|
|
50
|
+
read: "Read",
|
|
51
|
+
bash: "Bash",
|
|
52
|
+
edit: "Edit",
|
|
53
|
+
write: "Write",
|
|
54
|
+
grep: "Grep",
|
|
55
|
+
find: "Find",
|
|
56
|
+
ls: "List",
|
|
57
|
+
};
|
|
58
|
+
/** Collapse whitespace and clip to `max`, appending an ellipsis when cut. */
|
|
59
|
+
function clip(text, max) {
|
|
60
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
61
|
+
if (collapsed.length <= max)
|
|
62
|
+
return collapsed;
|
|
63
|
+
return `${collapsed.slice(0, max - 1)}…`;
|
|
64
|
+
}
|
|
65
|
+
/** Forward slashes for comparison and display, on every platform. */
|
|
66
|
+
function slashed(p) {
|
|
67
|
+
return p.replace(/\\/g, "/");
|
|
68
|
+
}
|
|
69
|
+
/** Home-collapse, and prefer cwd-relative for paths inside the project. */
|
|
70
|
+
export function displayPath(rawPath, cwd) {
|
|
71
|
+
if (!rawPath)
|
|
72
|
+
return rawPath;
|
|
73
|
+
let display = slashed(rawPath);
|
|
74
|
+
if (cwd) {
|
|
75
|
+
// Resolve BOTH sides so a POSIX-style cwd and Windows' drive-letter
|
|
76
|
+
// absolute paths compare in the same space.
|
|
77
|
+
const absolute = slashed(path.resolve(cwd, rawPath));
|
|
78
|
+
const base = slashed(path.resolve(cwd)).replace(/\/+$/, "");
|
|
79
|
+
if (absolute.startsWith(`${base}/`))
|
|
80
|
+
return absolute.slice(base.length + 1);
|
|
81
|
+
display = absolute;
|
|
82
|
+
}
|
|
83
|
+
const home = slashed(os.homedir());
|
|
84
|
+
return display.startsWith(home) ? `~${display.slice(home.length)}` : display;
|
|
85
|
+
}
|
|
86
|
+
/** Whether a tool-call path targets the session scratchpad. */
|
|
87
|
+
export function isScratchpadPath(rawPath, scratchpadDir, cwd) {
|
|
88
|
+
if (typeof rawPath !== "string" || !rawPath || !scratchpadDir)
|
|
89
|
+
return false;
|
|
90
|
+
const absolute = slashed(path.resolve(cwd ?? process.cwd(), rawPath));
|
|
91
|
+
const base = slashed(path.resolve(scratchpadDir)).replace(/\/+$/, "");
|
|
92
|
+
return absolute === base || absolute.startsWith(`${base}/`);
|
|
93
|
+
}
|
|
94
|
+
/** The one argument worth showing in a title: path, command, or pattern. */
|
|
95
|
+
export function primaryArg(name, args, cwd) {
|
|
96
|
+
const a = args ?? {};
|
|
97
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
98
|
+
switch (name) {
|
|
99
|
+
case "bash":
|
|
100
|
+
return clip(str(a.command) || "…", ARG_PREVIEW_MAX);
|
|
101
|
+
case "grep": {
|
|
102
|
+
const pattern = str(a.pattern) || "…";
|
|
103
|
+
const where = str(a.path);
|
|
104
|
+
return clip(where ? `${pattern} in ${displayPath(where, cwd)}` : pattern, ARG_PREVIEW_MAX);
|
|
105
|
+
}
|
|
106
|
+
case "find":
|
|
107
|
+
return clip(str(a.pattern) || "…", ARG_PREVIEW_MAX);
|
|
108
|
+
case "ls":
|
|
109
|
+
return clip(displayPath(str(a.path) || ".", cwd), ARG_PREVIEW_MAX);
|
|
110
|
+
default: {
|
|
111
|
+
const p = str(a.path);
|
|
112
|
+
let display = p ? displayPath(p, cwd) : "…";
|
|
113
|
+
if (name === "read" && (a.offset !== undefined || a.limit !== undefined)) {
|
|
114
|
+
const start = typeof a.offset === "number" ? a.offset : 1;
|
|
115
|
+
const end = typeof a.limit === "number" ? start + a.limit - 1 : undefined;
|
|
116
|
+
display += `:${start}${end !== undefined ? `-${end}` : ""}`;
|
|
117
|
+
}
|
|
118
|
+
return clip(display, ARG_PREVIEW_MAX);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function statusDot(status, theme) {
|
|
123
|
+
if (status === "error")
|
|
124
|
+
return theme.fg("error", "●");
|
|
125
|
+
if (status === "running")
|
|
126
|
+
return theme.fg("accent", "●");
|
|
127
|
+
return theme.fg("success", "●");
|
|
128
|
+
}
|
|
129
|
+
/** `● Tool(arg)` — the visible-row (and live-row) title line. */
|
|
130
|
+
export function formatRowTitle(name, args, status, theme, cwd) {
|
|
131
|
+
const title = theme.bold(theme.fg("toolTitle", TITLE_BY_NAME[name]));
|
|
132
|
+
return `${statusDot(status, theme)} ${title}(${theme.fg("accent", primaryArg(name, args, cwd))})`;
|
|
133
|
+
}
|
|
134
|
+
/** Extract "Command exited with code N" from a bash error body, if present. */
|
|
135
|
+
export function splitBashError(outputText) {
|
|
136
|
+
const match = outputText.match(/\n*(Command exited with code \d+)\s*$/);
|
|
137
|
+
if (!match)
|
|
138
|
+
return { output: outputText.trimEnd(), exitLine: undefined };
|
|
139
|
+
return { output: outputText.slice(0, match.index).trimEnd(), exitLine: match[1] };
|
|
140
|
+
}
|
|
141
|
+
function indent(lines, pad) {
|
|
142
|
+
return lines.map((line) => pad + line);
|
|
143
|
+
}
|
|
144
|
+
/** `⎿ exit line` + a dim tail of output under a failed shell command. */
|
|
145
|
+
export function formatBashErrorBody(outputText, theme) {
|
|
146
|
+
const { output, exitLine } = splitBashError(outputText);
|
|
147
|
+
const lines = [];
|
|
148
|
+
lines.push(` ${theme.fg("muted", "⎿")} ${theme.fg("error", exitLine ?? "Command failed")}`);
|
|
149
|
+
const tail = output.split("\n").filter((l) => l.length > 0).slice(-ERROR_TAIL_LINES);
|
|
150
|
+
lines.push(...indent(tail.map((l) => theme.fg("toolOutput", l)), " "));
|
|
151
|
+
return lines;
|
|
152
|
+
}
|
|
153
|
+
/** A dim tail of live output under a still-running shell command. */
|
|
154
|
+
export function formatBashPartialBody(outputText, theme) {
|
|
155
|
+
const tail = outputText.split("\n").filter((l) => l.length > 0).slice(-PARTIAL_TAIL_LINES);
|
|
156
|
+
return indent(tail.map((l) => theme.fg("dim", l)), " ");
|
|
157
|
+
}
|
|
158
|
+
/** Count added lines in a unified patch (edit summaries surface "+N"). */
|
|
159
|
+
export function countPatchAdditions(patch) {
|
|
160
|
+
if (!patch)
|
|
161
|
+
return 0;
|
|
162
|
+
let added = 0;
|
|
163
|
+
for (const line of patch.split("\n")) {
|
|
164
|
+
if (line.startsWith("+") && !line.startsWith("+++"))
|
|
165
|
+
added += 1;
|
|
166
|
+
}
|
|
167
|
+
return added;
|
|
168
|
+
}
|
|
169
|
+
function countPatchRemovals(patch) {
|
|
170
|
+
if (!patch)
|
|
171
|
+
return 0;
|
|
172
|
+
let removed = 0;
|
|
173
|
+
for (const line of patch.split("\n")) {
|
|
174
|
+
if (line.startsWith("-") && !line.startsWith("---"))
|
|
175
|
+
removed += 1;
|
|
176
|
+
}
|
|
177
|
+
return removed;
|
|
178
|
+
}
|
|
179
|
+
const EXPAND_HINT = "(ctrl+o to expand)";
|
|
180
|
+
/**
|
|
181
|
+
* `⎿ Wrote N lines to path` + a numbered, syntax-highlighted preview.
|
|
182
|
+
* `highlight` is injectable so tests stay independent of pi's theme state.
|
|
183
|
+
*/
|
|
184
|
+
export function formatWriteBody(rawPath, content, expanded, theme, cwd, highlight = highlightForPath) {
|
|
185
|
+
const allLines = content.replace(/\n$/, "").split("\n");
|
|
186
|
+
const total = allLines.length;
|
|
187
|
+
const shownCount = expanded ? Math.min(total, EXPANDED_MAX_LINES) : Math.min(total, WRITE_PREVIEW_LINES);
|
|
188
|
+
const head = ` ${theme.fg("muted", "⎿")} Wrote ${total} ${total === 1 ? "line" : "lines"} to ${theme.fg("accent", displayPath(rawPath, cwd))}`;
|
|
189
|
+
const shown = allLines.slice(0, shownCount);
|
|
190
|
+
const painted = highlight(shown.join("\n"), rawPath);
|
|
191
|
+
const numberWidth = String(shownCount).length;
|
|
192
|
+
const body = painted
|
|
193
|
+
.slice(0, shownCount)
|
|
194
|
+
.map((line, i) => ` ${theme.fg("dim", String(i + 1).padStart(numberWidth))} ${line}`);
|
|
195
|
+
const lines = [head, ...body];
|
|
196
|
+
if (total > shownCount) {
|
|
197
|
+
lines.push(` ${theme.fg("muted", `… +${total - shownCount} lines ${EXPAND_HINT}`)}`);
|
|
198
|
+
}
|
|
199
|
+
return lines;
|
|
200
|
+
}
|
|
201
|
+
/** Default highlighter: pi's theme-backed syntax highlighting, failing to plain lines. */
|
|
202
|
+
function highlightForPath(code, filePath) {
|
|
203
|
+
try {
|
|
204
|
+
return highlightCode(code, getLanguageFromPath(filePath));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return code.split("\n");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* `⎿ Updated path (+A -R)` + a colored diff preview.
|
|
212
|
+
* `paintDiff` is injectable for the same reason as `highlight` above.
|
|
213
|
+
*/
|
|
214
|
+
export function formatEditBody(rawPath, details, expanded, theme, cwd, paintDiff = defaultPaintDiff) {
|
|
215
|
+
const added = countPatchAdditions(details?.patch);
|
|
216
|
+
const removed = countPatchRemovals(details?.patch);
|
|
217
|
+
const counts = added || removed
|
|
218
|
+
? ` (${[added ? theme.fg("success", `+${added}`) : "", removed ? theme.fg("error", `-${removed}`) : ""]
|
|
219
|
+
.filter(Boolean)
|
|
220
|
+
.join(" ")})`
|
|
221
|
+
: "";
|
|
222
|
+
const head = ` ${theme.fg("muted", "⎿")} Updated ${theme.fg("accent", displayPath(rawPath, cwd))}${counts}`;
|
|
223
|
+
const diffText = details?.diff;
|
|
224
|
+
if (!diffText)
|
|
225
|
+
return [head];
|
|
226
|
+
const diffLines = paintDiff(diffText).split("\n");
|
|
227
|
+
const cap = expanded ? EXPANDED_MAX_LINES : DIFF_PREVIEW_LINES;
|
|
228
|
+
const lines = [head, ...indent(diffLines.slice(0, cap), " ")];
|
|
229
|
+
if (diffLines.length > cap) {
|
|
230
|
+
lines.push(` ${theme.fg("muted", `… +${diffLines.length - cap} lines ${EXPAND_HINT}`)}`);
|
|
231
|
+
}
|
|
232
|
+
return lines;
|
|
233
|
+
}
|
|
234
|
+
function defaultPaintDiff(diffText) {
|
|
235
|
+
try {
|
|
236
|
+
return renderDiff(diffText);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return diffText;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Expanded body: the raw output, dimmed and capped. */
|
|
243
|
+
export function formatExpandedOutput(outputText, theme) {
|
|
244
|
+
const lines = outputText.replace(/\n$/, "").split("\n");
|
|
245
|
+
const shown = lines.slice(0, EXPANDED_MAX_LINES).map((l) => theme.fg("toolOutput", l));
|
|
246
|
+
if (lines.length > EXPANDED_MAX_LINES) {
|
|
247
|
+
shown.push(theme.fg("muted", `… +${lines.length - EXPANDED_MAX_LINES} lines`));
|
|
248
|
+
}
|
|
249
|
+
return shown;
|
|
250
|
+
}
|
|
251
|
+
function textOf(result) {
|
|
252
|
+
return (result?.content ?? [])
|
|
253
|
+
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
254
|
+
.map((c) => c.text)
|
|
255
|
+
.join("\n");
|
|
256
|
+
}
|
|
257
|
+
function hasImages(result) {
|
|
258
|
+
return (result?.content ?? []).some((c) => c.type === "image");
|
|
259
|
+
}
|
|
260
|
+
function empty() {
|
|
261
|
+
return new Container();
|
|
262
|
+
}
|
|
263
|
+
function textComponent(lines) {
|
|
264
|
+
return lines.length === 0 ? empty() : new Text(lines.join("\n"), OUTPUT_PAD, 0);
|
|
265
|
+
}
|
|
266
|
+
function defaultLoadSettings(cwd) {
|
|
267
|
+
return SettingsManager.create(cwd, getAgentDir());
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Re-register the seven built-ins with condensed renderers. Returns the
|
|
271
|
+
* tracker so callers (and tests) can feed message boundaries into it.
|
|
272
|
+
*/
|
|
273
|
+
export function registerCondensedTools(pi, deps = {}) {
|
|
274
|
+
const tracker = new ToolRunTracker();
|
|
275
|
+
const registrationCwd = deps.cwd ?? process.cwd();
|
|
276
|
+
// Assistant/user prose breaks a run; toolResult messages must not (they are
|
|
277
|
+
// interleaved between the very rows a run aggregates).
|
|
278
|
+
pi.on("message_start", (event) => {
|
|
279
|
+
const role = event.message?.role;
|
|
280
|
+
if (role === "assistant" || role === "user")
|
|
281
|
+
tracker.markBreak();
|
|
282
|
+
});
|
|
283
|
+
let toolOptions;
|
|
284
|
+
try {
|
|
285
|
+
const settings = (deps.loadSettings ?? defaultLoadSettings)(registrationCwd);
|
|
286
|
+
toolOptions = {
|
|
287
|
+
bash: { shellPath: settings.getShellPath(), commandPrefix: settings.getShellCommandPrefix() },
|
|
288
|
+
read: { autoResizeImages: settings.getImageAutoResize() },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
toolOptions = { bash: {}, read: {} };
|
|
293
|
+
}
|
|
294
|
+
const buildDefinitions = (cwd) => ({
|
|
295
|
+
read: createReadToolDefinition(cwd, toolOptions.read),
|
|
296
|
+
bash: createBashToolDefinition(cwd, toolOptions.bash),
|
|
297
|
+
edit: createEditToolDefinition(cwd),
|
|
298
|
+
write: createWriteToolDefinition(cwd),
|
|
299
|
+
grep: createGrepToolDefinition(cwd),
|
|
300
|
+
find: createFindToolDefinition(cwd),
|
|
301
|
+
ls: createLsToolDefinition(cwd),
|
|
302
|
+
});
|
|
303
|
+
const definitionsByCwd = new Map();
|
|
304
|
+
const definitionsFor = (cwd) => {
|
|
305
|
+
let defs = definitionsByCwd.get(cwd);
|
|
306
|
+
if (!defs) {
|
|
307
|
+
defs = buildDefinitions(cwd);
|
|
308
|
+
definitionsByCwd.set(cwd, defs);
|
|
309
|
+
}
|
|
310
|
+
return defs;
|
|
311
|
+
};
|
|
312
|
+
definitionsByCwd.set(registrationCwd, buildDefinitions(registrationCwd));
|
|
313
|
+
for (const name of BUILTIN_NAMES) {
|
|
314
|
+
const base = definitionsFor(registrationCwd)[name];
|
|
315
|
+
const kind = kindForTool(name);
|
|
316
|
+
const upsertFromCall = (context) => {
|
|
317
|
+
const args = context.args;
|
|
318
|
+
return tracker.upsert(context.toolCallId, {
|
|
319
|
+
kind,
|
|
320
|
+
scratchpad: isScratchpadPath(args?.path, deps.scratchpadDir, context.cwd),
|
|
321
|
+
error: context.isError,
|
|
322
|
+
final: !context.isPartial,
|
|
323
|
+
invalidate: context.invalidate,
|
|
324
|
+
});
|
|
325
|
+
};
|
|
326
|
+
pi.registerTool({
|
|
327
|
+
...base,
|
|
328
|
+
renderShell: "self",
|
|
329
|
+
execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
330
|
+
const cwd = ctx?.cwd ?? registrationCwd;
|
|
331
|
+
return definitionsFor(cwd)[name].execute(toolCallId, params, signal, onUpdate, ctx);
|
|
332
|
+
},
|
|
333
|
+
renderCall(args, theme, context) {
|
|
334
|
+
const slice = context;
|
|
335
|
+
const row = upsertFromCall(slice);
|
|
336
|
+
const status = slice.isError ? "error" : slice.isPartial ? "running" : "ok";
|
|
337
|
+
const argsRecord = args;
|
|
338
|
+
if (slice.expanded || !isQuiet(row)) {
|
|
339
|
+
return new Text(formatRowTitle(name, argsRecord, status, theme, slice.cwd), OUTPUT_PAD, 0);
|
|
340
|
+
}
|
|
341
|
+
if (!row.final) {
|
|
342
|
+
return new Text(formatRowTitle(name, argsRecord, "running", theme, slice.cwd), OUTPUT_PAD, 0);
|
|
343
|
+
}
|
|
344
|
+
const summary = tracker.summaryFor(row.id);
|
|
345
|
+
return summary ? new Text(theme.fg("muted", summary), OUTPUT_PAD, 0) : empty();
|
|
346
|
+
},
|
|
347
|
+
renderResult(result, options, theme, context) {
|
|
348
|
+
const slice = context;
|
|
349
|
+
const args = (slice.args ?? {});
|
|
350
|
+
const row = tracker.upsert(slice.toolCallId, {
|
|
351
|
+
kind,
|
|
352
|
+
final: !options.isPartial,
|
|
353
|
+
error: slice.isError,
|
|
354
|
+
images: hasImages(result),
|
|
355
|
+
added: name === "write" && typeof args.content === "string"
|
|
356
|
+
? args.content.replace(/\n$/, "").split("\n").length
|
|
357
|
+
: name === "edit"
|
|
358
|
+
? countPatchAdditions(result.details?.patch)
|
|
359
|
+
: 0,
|
|
360
|
+
});
|
|
361
|
+
if (options.expanded) {
|
|
362
|
+
if (name === "write" && typeof args.path === "string" && typeof args.content === "string" && !slice.isError) {
|
|
363
|
+
return textComponent(formatWriteBody(args.path, args.content, true, theme, slice.cwd));
|
|
364
|
+
}
|
|
365
|
+
if (name === "edit" && typeof args.path === "string" && !slice.isError && result.details) {
|
|
366
|
+
return textComponent(formatEditBody(args.path, result.details, true, theme, slice.cwd));
|
|
367
|
+
}
|
|
368
|
+
const output = textOf(result);
|
|
369
|
+
return output ? textComponent(formatExpandedOutput(output, theme)) : empty();
|
|
370
|
+
}
|
|
371
|
+
if (name === "bash" && options.isPartial) {
|
|
372
|
+
return textComponent(formatBashPartialBody(textOf(result), theme));
|
|
373
|
+
}
|
|
374
|
+
if (isQuiet(row))
|
|
375
|
+
return empty();
|
|
376
|
+
if (slice.isError) {
|
|
377
|
+
if (name === "bash")
|
|
378
|
+
return textComponent(formatBashErrorBody(textOf(result), theme));
|
|
379
|
+
const message = textOf(result).split("\n").filter(Boolean).slice(-ERROR_TAIL_LINES);
|
|
380
|
+
return textComponent(message.map((l, i) => (i === 0 ? ` ${theme.fg("muted", "⎿")} ${theme.fg("error", l)}` : ` ${theme.fg("toolOutput", l)}`)));
|
|
381
|
+
}
|
|
382
|
+
if (name === "write" && typeof args.path === "string" && typeof args.content === "string") {
|
|
383
|
+
return textComponent(formatWriteBody(args.path, args.content, false, theme, slice.cwd));
|
|
384
|
+
}
|
|
385
|
+
if (name === "edit" && typeof args.path === "string") {
|
|
386
|
+
return textComponent(formatEditBody(args.path, result.details, false, theme, slice.cwd));
|
|
387
|
+
}
|
|
388
|
+
// Visible for another reason (e.g. an image read): title already
|
|
389
|
+
// painted by renderCall; images render themselves below the row.
|
|
390
|
+
return empty();
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return tracker;
|
|
395
|
+
}
|
|
396
|
+
//# sourceMappingURL=condensedTools.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Committed branch diff stat for the footer's git badge.
|
|
3
|
+
*
|
|
4
|
+
* Counting follows Codex (`tui/src/branch_summary.rs`), not Kimi: it sums the
|
|
5
|
+
* line delta between `HEAD` and the merge base with the repository's *default*
|
|
6
|
+
* branch, so the badge answers "what does this branch contain" — the same
|
|
7
|
+
* number a GitHub PR UI would show. Uncommitted working-tree edits are
|
|
8
|
+
* intentionally ignored (Codex: "the status-line item summarizes the checked-
|
|
9
|
+
* out branch, not the current dirty worktree").
|
|
10
|
+
*
|
|
11
|
+
* Display follows Kimi: `[+N -M]` appended to the branch, dim grey.
|
|
12
|
+
*
|
|
13
|
+
* Refresh reuses Kimi's synchronous TTL model: re-read at most every 15s,
|
|
14
|
+
* demanded on each footer render. The only divergence from Codex is that their
|
|
15
|
+
* probe is `async`; ours is `spawnSync` because the footer render path is
|
|
16
|
+
* synchronous.
|
|
17
|
+
*/
|
|
18
|
+
/** Committed branch delta relative to the default branch. */
|
|
19
|
+
export interface BranchDiffStat {
|
|
20
|
+
readonly added: number;
|
|
21
|
+
readonly deleted: number;
|
|
22
|
+
}
|
|
23
|
+
/** Diff-stat source with a workDir, callable on demand. */
|
|
24
|
+
export interface DiffStatCache {
|
|
25
|
+
/** Current stat, or `null` when workDir isn't a git repo. */
|
|
26
|
+
get(): BranchDiffStat | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Injectable git runner seam (tests stub it; production shells out to `git`).
|
|
30
|
+
* Returns stdout, or `null` on any failure.
|
|
31
|
+
*/
|
|
32
|
+
export type GitRunner = (args: string[], cwd: string) => string | null;
|
|
33
|
+
/** True when `cwd` is inside a git work tree. Never throws. */
|
|
34
|
+
export declare function detectGitRepo(run: GitRunner, cwd: string): boolean;
|
|
35
|
+
/** Codex's `parseDiffNumstatCount`-equivalent: `-` (binary) → 0; non-numeric → 0. */
|
|
36
|
+
export declare function parseNumstatCount(value: string | undefined): number;
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the default branch name, preferring remote-tracking refs over local
|
|
39
|
+
* branches so a stale local `main` doesn't inflate the diff (Codex's ordering).
|
|
40
|
+
* Falls back to common local names when no remote advertises a default.
|
|
41
|
+
* Returns null when nothing resolves.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveDefaultBranch(run: GitRunner, cwd: string): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Sum the committed line delta between `HEAD` and the merge base with the
|
|
46
|
+
* default branch: `git diff --numstat <merge-base>..HEAD`. Returns null when
|
|
47
|
+
* the default branch or merge base can't be resolved (hide the badge).
|
|
48
|
+
*/
|
|
49
|
+
export declare function readDiffStat(run: GitRunner, cwd: string): BranchDiffStat | null;
|
|
50
|
+
/**
|
|
51
|
+
* Kimi-style display: `+N -M`, or null when the branch is even with its base
|
|
52
|
+
* (a fresh/unchanged branch shows no badge). Both-zero still renders nothing,
|
|
53
|
+
* matching "our style" (Kimi hides a clean tree; Codex would say "No changes").
|
|
54
|
+
*/
|
|
55
|
+
export declare function formatDiffStat(diff: BranchDiffStat | null): string | null;
|
|
56
|
+
/**
|
|
57
|
+
* Time-bucketed cache: `get()` re-reads git only when the last read was more
|
|
58
|
+
* than 15s ago, null off-repo. All git failures → null (badge hidden), never
|
|
59
|
+
* throws.
|
|
60
|
+
*/
|
|
61
|
+
export declare function createDiffStatCache(workDir: string, run?: GitRunner): DiffStatCache;
|
|
62
|
+
//# sourceMappingURL=diffStat.d.ts.map
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Committed branch diff stat for the footer's git badge.
|
|
3
|
+
*
|
|
4
|
+
* Counting follows Codex (`tui/src/branch_summary.rs`), not Kimi: it sums the
|
|
5
|
+
* line delta between `HEAD` and the merge base with the repository's *default*
|
|
6
|
+
* branch, so the badge answers "what does this branch contain" — the same
|
|
7
|
+
* number a GitHub PR UI would show. Uncommitted working-tree edits are
|
|
8
|
+
* intentionally ignored (Codex: "the status-line item summarizes the checked-
|
|
9
|
+
* out branch, not the current dirty worktree").
|
|
10
|
+
*
|
|
11
|
+
* Display follows Kimi: `[+N -M]` appended to the branch, dim grey.
|
|
12
|
+
*
|
|
13
|
+
* Refresh reuses Kimi's synchronous TTL model: re-read at most every 15s,
|
|
14
|
+
* demanded on each footer render. The only divergence from Codex is that their
|
|
15
|
+
* probe is `async`; ours is `spawnSync` because the footer render path is
|
|
16
|
+
* synchronous.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
/** Status refresh interval — same value Kimi uses (STATUS_TTL_MS). */
|
|
20
|
+
const STATUS_TTL_MS = 15_000;
|
|
21
|
+
/** git spawn timeout — same value Kimi uses (SPAWN_TIMEOUT_MS). */
|
|
22
|
+
const SPAWN_TIMEOUT_MS = 500;
|
|
23
|
+
const SPAWN_MAX_BUFFER = 4 * 1024 * 1024;
|
|
24
|
+
/** Production runner: `spawnSync` with a short timeout, all errors → null. */
|
|
25
|
+
function runGit(args, cwd) {
|
|
26
|
+
try {
|
|
27
|
+
const r = spawnSync("git", ["--no-optional-locks", ...args], {
|
|
28
|
+
cwd,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
31
|
+
maxBuffer: SPAWN_MAX_BUFFER,
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
33
|
+
});
|
|
34
|
+
if (r.error || r.status !== 0)
|
|
35
|
+
return null;
|
|
36
|
+
return r.stdout;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** True when `cwd` is inside a git work tree. Never throws. */
|
|
43
|
+
export function detectGitRepo(run, cwd) {
|
|
44
|
+
const out = run(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
45
|
+
return out !== null && out.trim() === "true";
|
|
46
|
+
}
|
|
47
|
+
/** Codex's `parseDiffNumstatCount`-equivalent: `-` (binary) → 0; non-numeric → 0. */
|
|
48
|
+
export function parseNumstatCount(value) {
|
|
49
|
+
if (value === undefined || value === "-")
|
|
50
|
+
return 0;
|
|
51
|
+
const n = Number.parseInt(value, 10);
|
|
52
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
53
|
+
}
|
|
54
|
+
/** List remotes, `origin` first (mirrors Codex's `get_git_remotes`). */
|
|
55
|
+
function gitRemotes(run, cwd) {
|
|
56
|
+
const out = run(["remote"], cwd);
|
|
57
|
+
if (out === null)
|
|
58
|
+
return [];
|
|
59
|
+
const remotes = out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
60
|
+
const originIdx = remotes.indexOf("origin");
|
|
61
|
+
if (originIdx > 0) {
|
|
62
|
+
remotes.splice(originIdx, 1);
|
|
63
|
+
remotes.unshift("origin");
|
|
64
|
+
}
|
|
65
|
+
return remotes;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the default branch name, preferring remote-tracking refs over local
|
|
69
|
+
* branches so a stale local `main` doesn't inflate the diff (Codex's ordering).
|
|
70
|
+
* Falls back to common local names when no remote advertises a default.
|
|
71
|
+
* Returns null when nothing resolves.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveDefaultBranch(run, cwd) {
|
|
74
|
+
// Tier 1: remote default via `refs/remotes/<remote>/HEAD` symbolic ref.
|
|
75
|
+
for (const remote of gitRemotes(run, cwd)) {
|
|
76
|
+
const out = run(["symbolic-ref", "--quiet", `refs/remotes/${remote}/HEAD`], cwd);
|
|
77
|
+
if (out === null)
|
|
78
|
+
continue;
|
|
79
|
+
const ref = out.trim();
|
|
80
|
+
if (!ref)
|
|
81
|
+
continue;
|
|
82
|
+
// `refs/remotes/origin/main` → `main`.
|
|
83
|
+
const name = ref.replace(/^refs\/remotes\/[^/]+\//, "");
|
|
84
|
+
if (name && name !== "HEAD")
|
|
85
|
+
return name;
|
|
86
|
+
}
|
|
87
|
+
// Tier 2: local main/master/trunk/develop.
|
|
88
|
+
for (const candidate of ["main", "master", "trunk", "develop"]) {
|
|
89
|
+
const out = run(["rev-parse", "--verify", "--quiet", candidate], cwd);
|
|
90
|
+
if (out !== null && out.trim())
|
|
91
|
+
return candidate;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Sum the committed line delta between `HEAD` and the merge base with the
|
|
97
|
+
* default branch: `git diff --numstat <merge-base>..HEAD`. Returns null when
|
|
98
|
+
* the default branch or merge base can't be resolved (hide the badge).
|
|
99
|
+
*/
|
|
100
|
+
export function readDiffStat(run, cwd) {
|
|
101
|
+
const defaultBranch = resolveDefaultBranch(run, cwd);
|
|
102
|
+
if (defaultBranch === null)
|
|
103
|
+
return null;
|
|
104
|
+
const mergeBase = run(["merge-base", "HEAD", defaultBranch], cwd);
|
|
105
|
+
if (mergeBase === null || !mergeBase.trim())
|
|
106
|
+
return null;
|
|
107
|
+
const out = run(["diff", "--numstat", `${mergeBase.trim()}..HEAD`, "--"], cwd);
|
|
108
|
+
if (out === null)
|
|
109
|
+
return null;
|
|
110
|
+
let added = 0;
|
|
111
|
+
let deleted = 0;
|
|
112
|
+
for (const line of out.split("\n")) {
|
|
113
|
+
if (!line)
|
|
114
|
+
continue;
|
|
115
|
+
const [addedText, deletedText] = line.split("\t");
|
|
116
|
+
added += parseNumstatCount(addedText);
|
|
117
|
+
deleted += parseNumstatCount(deletedText);
|
|
118
|
+
}
|
|
119
|
+
return { added, deleted };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Kimi-style display: `+N -M`, or null when the branch is even with its base
|
|
123
|
+
* (a fresh/unchanged branch shows no badge). Both-zero still renders nothing,
|
|
124
|
+
* matching "our style" (Kimi hides a clean tree; Codex would say "No changes").
|
|
125
|
+
*/
|
|
126
|
+
export function formatDiffStat(diff) {
|
|
127
|
+
if (diff === null)
|
|
128
|
+
return null;
|
|
129
|
+
const parts = [];
|
|
130
|
+
if (diff.added > 0)
|
|
131
|
+
parts.push(`+${String(diff.added)}`);
|
|
132
|
+
if (diff.deleted > 0)
|
|
133
|
+
parts.push(`-${String(diff.deleted)}`);
|
|
134
|
+
return parts.length > 0 ? parts.join(" ") : null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Time-bucketed cache: `get()` re-reads git only when the last read was more
|
|
138
|
+
* than 15s ago, null off-repo. All git failures → null (badge hidden), never
|
|
139
|
+
* throws.
|
|
140
|
+
*/
|
|
141
|
+
export function createDiffStatCache(workDir, run = runGit) {
|
|
142
|
+
const isRepo = detectGitRepo(run, workDir);
|
|
143
|
+
let cached = null;
|
|
144
|
+
let fetchedAt = 0;
|
|
145
|
+
return {
|
|
146
|
+
get() {
|
|
147
|
+
if (!isRepo)
|
|
148
|
+
return null;
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
if (cached === null || now - fetchedAt >= STATUS_TTL_MS) {
|
|
151
|
+
cached = readDiffStat(run, workDir);
|
|
152
|
+
fetchedAt = now;
|
|
153
|
+
}
|
|
154
|
+
return cached;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=diffStat.js.map
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
* — useful reference for what data to replicate and how to format it.
|
|
40
40
|
*/
|
|
41
41
|
import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
42
|
+
import type { ChildUsageHandle } from "./childUsage.js";
|
|
42
43
|
import type { ModeHolder, PermissionMode } from "./permission/gate.js";
|
|
43
44
|
export declare const BRANCH_MAX_WIDTH = 60;
|
|
44
45
|
export declare function cyclePermissionMode(current: PermissionMode): PermissionMode;
|
|
@@ -96,7 +97,20 @@ export declare function renderFooterLines(input: {
|
|
|
96
97
|
model: string;
|
|
97
98
|
/** Current permission mode; shown on line 2 to the left of the model as "<mode> mode". */
|
|
98
99
|
mode?: PermissionMode | null;
|
|
100
|
+
/** Formatted `+N -M` / `±`, or null when clean — appended to the branch as `[ … ]`. */
|
|
101
|
+
diff?: string | null;
|
|
99
102
|
usage: UsageTotals;
|
|
103
|
+
/**
|
|
104
|
+
* Session child-process spend (subagents, advisor consults, Guardian
|
|
105
|
+
* reviews, /go runs) — childUsage.ts's session accumulator. Added into
|
|
106
|
+
* the line-2 stats so the footer's totals include the spend the per-child
|
|
107
|
+
* receipts already print; absent/zero changes nothing.
|
|
108
|
+
*/
|
|
109
|
+
childUsage?: {
|
|
110
|
+
input: number;
|
|
111
|
+
output: number;
|
|
112
|
+
cost: number;
|
|
113
|
+
};
|
|
100
114
|
contextPercent: number | null;
|
|
101
115
|
statuses: string[];
|
|
102
116
|
}, theme: Pick<Theme, "fg">, width: number, padX?: number): string[];
|
|
@@ -109,7 +123,7 @@ export interface FooterInvalidateHandle {
|
|
|
109
123
|
invalidateGit(): void;
|
|
110
124
|
requestRender(): void;
|
|
111
125
|
}
|
|
112
|
-
export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder, invalidateHandle?: FooterInvalidateHandle): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
|
|
126
|
+
export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder, invalidateHandle?: FooterInvalidateHandle, childUsage?: ChildUsageHandle): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
|
|
113
127
|
render(width: number): string[];
|
|
114
128
|
invalidate(): void;
|
|
115
129
|
dispose(): void;
|