@oh-my-pi/pi-coding-agent 3.20.1 → 3.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -9
- package/docs/custom-tools.md +3 -3
- package/docs/extensions.md +226 -220
- package/docs/hooks.md +2 -2
- package/docs/sdk.md +3 -3
- package/examples/custom-tools/README.md +2 -2
- package/examples/custom-tools/subagent/index.ts +1 -1
- package/examples/extensions/README.md +76 -74
- package/examples/extensions/todo.ts +2 -5
- package/examples/hooks/custom-compaction.ts +1 -1
- package/examples/hooks/handoff.ts +1 -1
- package/examples/hooks/qna.ts +1 -1
- package/examples/sdk/02-custom-model.ts +1 -1
- package/examples/sdk/12-full-control.ts +1 -1
- package/examples/sdk/README.md +1 -1
- package/package.json +5 -5
- package/src/cli/file-processor.ts +1 -1
- package/src/cli/list-models.ts +1 -1
- package/src/core/agent-session.ts +13 -2
- package/src/core/auth-storage.ts +1 -1
- package/src/core/compaction/branch-summarization.ts +2 -2
- package/src/core/compaction/compaction.ts +2 -2
- package/src/core/compaction/utils.ts +1 -1
- package/src/core/custom-tools/types.ts +1 -1
- package/src/core/extensions/runner.ts +1 -1
- package/src/core/extensions/types.ts +1 -1
- package/src/core/extensions/wrapper.ts +1 -1
- package/src/core/hooks/runner.ts +2 -2
- package/src/core/hooks/types.ts +1 -1
- package/src/core/messages.ts +1 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/model-resolver.ts +1 -1
- package/src/core/sdk.ts +33 -4
- package/src/core/session-manager.ts +11 -22
- package/src/core/settings-manager.ts +66 -1
- package/src/core/slash-commands.ts +12 -5
- package/src/core/system-prompt.ts +27 -3
- package/src/core/title-generator.ts +2 -2
- package/src/core/tools/ask.ts +88 -1
- package/src/core/tools/bash-interceptor.ts +7 -0
- package/src/core/tools/bash.ts +106 -0
- package/src/core/tools/edit-diff.ts +73 -24
- package/src/core/tools/edit.ts +214 -20
- package/src/core/tools/find.ts +155 -0
- package/src/core/tools/gemini-image.ts +279 -56
- package/src/core/tools/git.ts +4 -0
- package/src/core/tools/grep.ts +191 -0
- package/src/core/tools/index.ts +3 -6
- package/src/core/tools/ls.ts +134 -1
- package/src/core/tools/lsp/render.ts +34 -14
- package/src/core/tools/notebook.ts +110 -0
- package/src/core/tools/output.ts +179 -7
- package/src/core/tools/read.ts +122 -9
- package/src/core/tools/render-utils.ts +241 -0
- package/src/core/tools/renderers.ts +40 -828
- package/src/core/tools/review.ts +26 -7
- package/src/core/tools/rulebook.ts +3 -1
- package/src/core/tools/task/index.ts +18 -3
- package/src/core/tools/task/render.ts +5 -0
- package/src/core/tools/task/types.ts +1 -1
- package/src/core/tools/truncate.ts +27 -1
- package/src/core/tools/web-fetch.ts +23 -15
- package/src/core/tools/web-search/index.ts +130 -45
- package/src/core/tools/web-search/providers/anthropic.ts +7 -2
- package/src/core/tools/web-search/providers/exa.ts +2 -1
- package/src/core/tools/web-search/providers/perplexity.ts +6 -1
- package/src/core/tools/web-search/render.ts +5 -0
- package/src/core/tools/web-search/types.ts +13 -0
- package/src/core/tools/write.ts +90 -0
- package/src/core/voice.ts +1 -1
- package/src/main.ts +1 -1
- package/src/modes/interactive/components/assistant-message.ts +1 -1
- package/src/modes/interactive/components/custom-message.ts +1 -1
- package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
- package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
- package/src/modes/interactive/components/footer.ts +1 -1
- package/src/modes/interactive/components/hook-message.ts +1 -1
- package/src/modes/interactive/components/model-selector.ts +1 -1
- package/src/modes/interactive/components/oauth-selector.ts +1 -1
- package/src/modes/interactive/components/settings-defs.ts +49 -0
- package/src/modes/interactive/components/status-line.ts +1 -1
- package/src/modes/interactive/components/tool-execution.ts +93 -538
- package/src/modes/interactive/interactive-mode.ts +19 -7
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/rpc/rpc-client.ts +1 -1
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/prompts/system-prompt.md +4 -0
- package/src/prompts/tools/gemini-image.md +5 -1
- package/src/prompts/tools/output.md +4 -0
- package/src/prompts/tools/web-fetch.md +1 -0
- package/src/prompts/tools/web-search.md +2 -0
- package/src/utils/image-convert.ts +8 -2
- package/src/utils/image-magick.ts +247 -0
- package/src/utils/image-resize.ts +53 -13
package/src/core/tools/read.ts
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
|
|
3
4
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
4
|
-
import type {
|
|
5
|
+
import type { Component } from "@oh-my-pi/pi-tui";
|
|
6
|
+
import { Text } from "@oh-my-pi/pi-tui";
|
|
5
7
|
import { Type } from "@sinclair/typebox";
|
|
6
8
|
import { globSync } from "glob";
|
|
9
|
+
import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme";
|
|
7
10
|
import readDescription from "../../prompts/tools/read.md" with { type: "text" };
|
|
8
11
|
import { formatDimensionNote, resizeImage } from "../../utils/image-resize";
|
|
9
12
|
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
|
|
10
13
|
import { ensureTool } from "../../utils/tools-manager";
|
|
14
|
+
import type { RenderResultOptions } from "../custom-tools/types";
|
|
11
15
|
import { untilAborted } from "../utils";
|
|
12
16
|
import { createLsTool } from "./ls";
|
|
13
17
|
import { resolveReadPath, resolveToCwd } from "./path-utils";
|
|
14
|
-
import {
|
|
18
|
+
import { replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_MAX_BYTES,
|
|
21
|
+
DEFAULT_MAX_LINES,
|
|
22
|
+
formatSize,
|
|
23
|
+
type TruncationResult,
|
|
24
|
+
truncateHead,
|
|
25
|
+
truncateStringToBytesFromStart,
|
|
26
|
+
} from "./truncate";
|
|
15
27
|
|
|
16
28
|
// Document types convertible via markitdown
|
|
17
29
|
const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
|
|
@@ -450,9 +462,9 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
|
|
|
450
462
|
let outputText = truncation.content;
|
|
451
463
|
|
|
452
464
|
if (truncation.truncated) {
|
|
453
|
-
outputText += `\n\n[Document converted via markitdown. Output truncated to $formatSize(
|
|
465
|
+
outputText += `\n\n[Document converted via markitdown. Output truncated to ${formatSize(
|
|
454
466
|
DEFAULT_MAX_BYTES,
|
|
455
|
-
)]`;
|
|
467
|
+
)}]`;
|
|
456
468
|
details = { truncation };
|
|
457
469
|
}
|
|
458
470
|
|
|
@@ -498,11 +510,21 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
|
|
|
498
510
|
let outputText: string;
|
|
499
511
|
|
|
500
512
|
if (truncation.firstLineExceedsLimit) {
|
|
501
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
513
|
+
const firstLine = allLines[startLine] ?? "";
|
|
514
|
+
const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
|
|
515
|
+
const snippet = truncateStringToBytesFromStart(firstLine, DEFAULT_MAX_BYTES);
|
|
516
|
+
const shownSize = formatSize(snippet.bytes);
|
|
517
|
+
|
|
518
|
+
outputText = snippet.text;
|
|
519
|
+
if (outputText.length > 0) {
|
|
520
|
+
outputText += `\n\n[Line ${startLineDisplay} is ${formatSize(
|
|
521
|
+
firstLineBytes,
|
|
522
|
+
)}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Showing first ${shownSize} of the line.]`;
|
|
523
|
+
} else {
|
|
524
|
+
outputText = `[Line ${startLineDisplay} is ${formatSize(
|
|
525
|
+
firstLineBytes,
|
|
526
|
+
)}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Unable to display a valid UTF-8 snippet.]`;
|
|
527
|
+
}
|
|
506
528
|
details = { truncation };
|
|
507
529
|
} else if (truncation.truncated) {
|
|
508
530
|
// Truncation occurred - build actionable notice
|
|
@@ -542,3 +564,94 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo
|
|
|
542
564
|
|
|
543
565
|
/** Default read tool using process.cwd() - for backwards compatibility */
|
|
544
566
|
export const readTool = createReadTool(process.cwd());
|
|
567
|
+
|
|
568
|
+
// =============================================================================
|
|
569
|
+
// TUI Renderer
|
|
570
|
+
// =============================================================================
|
|
571
|
+
|
|
572
|
+
interface ReadRenderArgs {
|
|
573
|
+
path?: string;
|
|
574
|
+
file_path?: string;
|
|
575
|
+
offset?: number;
|
|
576
|
+
limit?: number;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "tiff"]);
|
|
580
|
+
const BINARY_EXTENSIONS = new Set(["pdf", "zip", "tar", "gz", "exe", "dll", "so", "dylib", "wasm"]);
|
|
581
|
+
|
|
582
|
+
function getFileType(filePath: string): "image" | "binary" | "text" {
|
|
583
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
584
|
+
if (!ext) return "text";
|
|
585
|
+
if (IMAGE_EXTENSIONS.has(ext)) return "image";
|
|
586
|
+
if (BINARY_EXTENSIONS.has(ext)) return "binary";
|
|
587
|
+
return "text";
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export const readToolRenderer = {
|
|
591
|
+
renderCall(args: ReadRenderArgs, uiTheme: Theme): Component {
|
|
592
|
+
const rawPath = args.file_path || args.path || "";
|
|
593
|
+
const filePath = shortenPath(rawPath);
|
|
594
|
+
const offset = args.offset;
|
|
595
|
+
const limit = args.limit;
|
|
596
|
+
|
|
597
|
+
let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
|
|
598
|
+
if (offset !== undefined || limit !== undefined) {
|
|
599
|
+
const startLine = offset ?? 1;
|
|
600
|
+
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
601
|
+
pathDisplay += uiTheme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const text = `${uiTheme.fg("toolTitle", uiTheme.bold("Read"))} ${pathDisplay}`;
|
|
605
|
+
return new Text(text, 0, 0);
|
|
606
|
+
},
|
|
607
|
+
|
|
608
|
+
renderResult(
|
|
609
|
+
result: { content: Array<{ type: string; text?: string }>; details?: ReadToolDetails },
|
|
610
|
+
{ expanded }: RenderResultOptions,
|
|
611
|
+
uiTheme: Theme,
|
|
612
|
+
args?: ReadRenderArgs,
|
|
613
|
+
): Component {
|
|
614
|
+
const rawPath = args?.file_path || args?.path || "";
|
|
615
|
+
const fileType = getFileType(rawPath);
|
|
616
|
+
const details = result.details;
|
|
617
|
+
const lines: string[] = [];
|
|
618
|
+
|
|
619
|
+
const output = result.content?.find((c) => c.type === "text")?.text ?? "";
|
|
620
|
+
|
|
621
|
+
if (fileType === "image") {
|
|
622
|
+
lines.push(uiTheme.fg("muted", "Image rendered below"));
|
|
623
|
+
} else if (fileType === "binary") {
|
|
624
|
+
// Binary files just show the header from renderCall
|
|
625
|
+
} else {
|
|
626
|
+
// Text file
|
|
627
|
+
const lang = getLanguageFromPath(rawPath);
|
|
628
|
+
const contentLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
|
|
629
|
+
|
|
630
|
+
if (expanded) {
|
|
631
|
+
lines.push(
|
|
632
|
+
...contentLines.map((line: string) =>
|
|
633
|
+
lang ? replaceTabs(line) : uiTheme.fg("toolOutput", replaceTabs(line)),
|
|
634
|
+
),
|
|
635
|
+
);
|
|
636
|
+
} else {
|
|
637
|
+
lines.push(uiTheme.fg("dim", `${uiTheme.nav.expand} Ctrl+O to show content`));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Truncation warning
|
|
641
|
+
const truncation = details?.truncation;
|
|
642
|
+
if (truncation?.truncated) {
|
|
643
|
+
let warning: string;
|
|
644
|
+
if (truncation.firstLineExceedsLimit) {
|
|
645
|
+
warning = `First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`;
|
|
646
|
+
} else if (truncation.truncatedBy === "lines") {
|
|
647
|
+
warning = `Truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)`;
|
|
648
|
+
} else {
|
|
649
|
+
warning = `Truncated: ${truncation.outputLines} lines (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`;
|
|
650
|
+
}
|
|
651
|
+
lines.push(uiTheme.fg("warning", wrapBrackets(warning, uiTheme)));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
656
|
+
},
|
|
657
|
+
};
|
|
@@ -204,6 +204,247 @@ export function formatMoreItems(remaining: number, itemType: string, theme: Them
|
|
|
204
204
|
return `${theme.format.ellipsis} ${safeRemaining} more ${pluralize(itemType, safeRemaining)}`;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
export function formatMeta(meta: string[], theme: Theme): string {
|
|
208
|
+
return meta.length > 0 ? ` ${theme.fg("muted", meta.join(theme.sep.dot))}` : "";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function formatScope(scopePath: string | undefined, theme: Theme): string {
|
|
212
|
+
return scopePath ? ` ${theme.fg("muted", `in ${scopePath}`)}` : "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function formatTruncationSuffix(truncated: boolean, theme: Theme): string {
|
|
216
|
+
return truncated ? theme.fg("warning", " (truncated)") : "";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function formatErrorMessage(message: string | undefined, theme: Theme): string {
|
|
220
|
+
const clean = (message ?? "").replace(/^Error:\s*/, "").trim();
|
|
221
|
+
return `${theme.styledSymbol("status.error", "error")} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function formatEmptyMessage(message: string, theme: Theme): string {
|
|
225
|
+
return `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", message)}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// =============================================================================
|
|
229
|
+
// Diagnostic Formatting
|
|
230
|
+
// =============================================================================
|
|
231
|
+
|
|
232
|
+
interface ParsedDiagnostic {
|
|
233
|
+
filePath: string;
|
|
234
|
+
line: number;
|
|
235
|
+
col: number;
|
|
236
|
+
severity: "error" | "warning" | "info" | "hint";
|
|
237
|
+
source?: string;
|
|
238
|
+
message: string;
|
|
239
|
+
code?: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseDiagnosticMessage(msg: string): ParsedDiagnostic | null {
|
|
243
|
+
const match = msg.match(/^(.+?):(\d+):(\d+)\s+\[(\w+)\]\s+(?:\[([^\]]+)\]\s+)?(.+?)(?:\s+\(([^)]+)\))?$/);
|
|
244
|
+
if (!match) return null;
|
|
245
|
+
return {
|
|
246
|
+
filePath: match[1],
|
|
247
|
+
line: parseInt(match[2], 10),
|
|
248
|
+
col: parseInt(match[3], 10),
|
|
249
|
+
severity: match[4] as ParsedDiagnostic["severity"],
|
|
250
|
+
source: match[5],
|
|
251
|
+
message: match[6],
|
|
252
|
+
code: match[7],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function formatDiagnostics(
|
|
257
|
+
diag: { errored: boolean; summary: string; messages: string[] },
|
|
258
|
+
expanded: boolean,
|
|
259
|
+
theme: Theme,
|
|
260
|
+
getLangIcon: (filePath: string) => string,
|
|
261
|
+
): string {
|
|
262
|
+
if (diag.messages.length === 0) return "";
|
|
263
|
+
|
|
264
|
+
const byFile = new Map<string, ParsedDiagnostic[]>();
|
|
265
|
+
const unparsed: string[] = [];
|
|
266
|
+
|
|
267
|
+
for (const msg of diag.messages) {
|
|
268
|
+
const parsed = parseDiagnosticMessage(msg);
|
|
269
|
+
if (parsed) {
|
|
270
|
+
const existing = byFile.get(parsed.filePath) ?? [];
|
|
271
|
+
existing.push(parsed);
|
|
272
|
+
byFile.set(parsed.filePath, existing);
|
|
273
|
+
} else {
|
|
274
|
+
unparsed.push(msg);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const headerIcon = diag.errored
|
|
279
|
+
? theme.styledSymbol("status.error", "error")
|
|
280
|
+
: theme.styledSymbol("status.warning", "warning");
|
|
281
|
+
let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
|
|
282
|
+
|
|
283
|
+
const maxDiags = expanded ? diag.messages.length : 5;
|
|
284
|
+
let shown = 0;
|
|
285
|
+
|
|
286
|
+
const files = Array.from(byFile.entries());
|
|
287
|
+
for (let fi = 0; fi < files.length && shown < maxDiags; fi++) {
|
|
288
|
+
const [filePath, diagnostics] = files[fi];
|
|
289
|
+
const isLastFile = fi === files.length - 1 && unparsed.length === 0;
|
|
290
|
+
const fileBranch = isLastFile ? theme.tree.last : theme.tree.branch;
|
|
291
|
+
|
|
292
|
+
const fileIcon = theme.fg("muted", getLangIcon(filePath));
|
|
293
|
+
output += `\n ${theme.fg("dim", fileBranch)} ${fileIcon} ${theme.fg("accent", filePath)}`;
|
|
294
|
+
shown++;
|
|
295
|
+
|
|
296
|
+
for (let di = 0; di < diagnostics.length && shown < maxDiags; di++) {
|
|
297
|
+
const d = diagnostics[di];
|
|
298
|
+
const isLastDiag = di === diagnostics.length - 1;
|
|
299
|
+
const diagBranch = isLastFile
|
|
300
|
+
? isLastDiag
|
|
301
|
+
? ` ${theme.tree.last}`
|
|
302
|
+
: ` ${theme.tree.branch}`
|
|
303
|
+
: isLastDiag
|
|
304
|
+
? ` ${theme.tree.vertical} ${theme.tree.last}`
|
|
305
|
+
: ` ${theme.tree.vertical} ${theme.tree.branch}`;
|
|
306
|
+
|
|
307
|
+
const sevIcon =
|
|
308
|
+
d.severity === "error"
|
|
309
|
+
? theme.styledSymbol("status.error", "error")
|
|
310
|
+
: d.severity === "warning"
|
|
311
|
+
? theme.styledSymbol("status.warning", "warning")
|
|
312
|
+
: theme.styledSymbol("status.info", "muted");
|
|
313
|
+
const location = theme.fg("dim", `:${d.line}:${d.col}`);
|
|
314
|
+
const codeTag = d.code ? theme.fg("dim", ` (${d.code})`) : "";
|
|
315
|
+
const msgColor = d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "toolOutput";
|
|
316
|
+
|
|
317
|
+
output += `\n ${theme.fg("dim", diagBranch)} ${sevIcon}${location} ${theme.fg(msgColor, d.message)}${codeTag}`;
|
|
318
|
+
shown++;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
for (const msg of unparsed) {
|
|
323
|
+
if (shown >= maxDiags) break;
|
|
324
|
+
const color = msg.includes("[error]") ? "error" : msg.includes("[warning]") ? "warning" : "dim";
|
|
325
|
+
output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg(color, msg)}`;
|
|
326
|
+
shown++;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (diag.messages.length > shown) {
|
|
330
|
+
const remaining = diag.messages.length - shown;
|
|
331
|
+
output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${remaining} more`)} ${theme.fg("dim", "(Ctrl+O to expand)")}`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return output;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// =============================================================================
|
|
338
|
+
// Diff Utilities
|
|
339
|
+
// =============================================================================
|
|
340
|
+
|
|
341
|
+
export interface DiffStats {
|
|
342
|
+
added: number;
|
|
343
|
+
removed: number;
|
|
344
|
+
hunks: number;
|
|
345
|
+
lines: number;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function getDiffStats(diffText: string): DiffStats {
|
|
349
|
+
const lines = diffText ? diffText.split("\n") : [];
|
|
350
|
+
let added = 0;
|
|
351
|
+
let removed = 0;
|
|
352
|
+
let hunks = 0;
|
|
353
|
+
let inHunk = false;
|
|
354
|
+
|
|
355
|
+
for (const line of lines) {
|
|
356
|
+
const isAdded = line.startsWith("+");
|
|
357
|
+
const isRemoved = line.startsWith("-");
|
|
358
|
+
const isChange = isAdded || isRemoved;
|
|
359
|
+
|
|
360
|
+
if (isAdded) added++;
|
|
361
|
+
if (isRemoved) removed++;
|
|
362
|
+
|
|
363
|
+
if (isChange && !inHunk) {
|
|
364
|
+
hunks++;
|
|
365
|
+
inHunk = true;
|
|
366
|
+
} else if (!isChange) {
|
|
367
|
+
inHunk = false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return { added, removed, hunks, lines: lines.length };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function formatDiffStats(added: number, removed: number, hunks: number, theme: Theme): string {
|
|
375
|
+
const parts: string[] = [];
|
|
376
|
+
if (added > 0) parts.push(theme.fg("success", `+${added}`));
|
|
377
|
+
if (removed > 0) parts.push(theme.fg("error", `-${removed}`));
|
|
378
|
+
if (hunks > 0) parts.push(theme.fg("dim", `${hunks} hunk${hunks !== 1 ? "s" : ""}`));
|
|
379
|
+
return parts.join(theme.fg("dim", " / "));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function truncateDiffByHunk(
|
|
383
|
+
diffText: string,
|
|
384
|
+
maxHunks: number,
|
|
385
|
+
maxLines: number,
|
|
386
|
+
): { text: string; hiddenHunks: number; hiddenLines: number } {
|
|
387
|
+
const lines = diffText ? diffText.split("\n") : [];
|
|
388
|
+
const totalStats = getDiffStats(diffText);
|
|
389
|
+
const kept: string[] = [];
|
|
390
|
+
let inHunk = false;
|
|
391
|
+
let currentHunks = 0;
|
|
392
|
+
let reachedLimit = false;
|
|
393
|
+
|
|
394
|
+
for (const line of lines) {
|
|
395
|
+
const isChange = line.startsWith("+") || line.startsWith("-");
|
|
396
|
+
if (isChange && !inHunk) {
|
|
397
|
+
currentHunks++;
|
|
398
|
+
inHunk = true;
|
|
399
|
+
}
|
|
400
|
+
if (!isChange) {
|
|
401
|
+
inHunk = false;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (currentHunks > maxHunks) {
|
|
405
|
+
reachedLimit = true;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
kept.push(line);
|
|
410
|
+
if (kept.length >= maxLines) {
|
|
411
|
+
reachedLimit = true;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!reachedLimit) {
|
|
417
|
+
return { text: diffText, hiddenHunks: 0, hiddenLines: 0 };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const keptStats = getDiffStats(kept.join("\n"));
|
|
421
|
+
return {
|
|
422
|
+
text: kept.join("\n"),
|
|
423
|
+
hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
|
|
424
|
+
hiddenLines: Math.max(0, totalStats.lines - kept.length),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// =============================================================================
|
|
429
|
+
// Path Utilities
|
|
430
|
+
// =============================================================================
|
|
431
|
+
|
|
432
|
+
export function shortenPath(filePath: string, homeDir?: string): string {
|
|
433
|
+
const home = homeDir ?? process.env.HOME ?? process.env.USERPROFILE;
|
|
434
|
+
if (home && filePath.startsWith(home)) {
|
|
435
|
+
return `~${filePath.slice(home.length)}`;
|
|
436
|
+
}
|
|
437
|
+
return filePath;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function wrapBrackets(text: string, theme: Theme): string {
|
|
441
|
+
return `${theme.format.bracketLeft}${text}${theme.format.bracketRight}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function replaceTabs(text: string): string {
|
|
445
|
+
return text.replace(/\t/g, " ");
|
|
446
|
+
}
|
|
447
|
+
|
|
207
448
|
function pluralize(label: string, count: number): string {
|
|
208
449
|
if (count === 1) return label;
|
|
209
450
|
if (/(?:ch|sh|s|x|z)$/i.test(label)) return `${label}es`;
|