pi-readseek 0.7.6 → 0.7.8
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 +28 -46
- package/dist/index.js +7333 -0
- package/package.json +8 -7
- package/index.ts +0 -147
- package/src/binary-detect.ts +0 -22
- package/src/check.ts +0 -92
- package/src/coerce-obvious-int.ts +0 -41
- package/src/confusable-hyphens.ts +0 -6
- package/src/def.ts +0 -143
- package/src/diff-data.ts +0 -302
- package/src/edit-classify.ts +0 -58
- package/src/edit-diff.ts +0 -359
- package/src/edit-output.ts +0 -110
- package/src/edit-render-helpers.ts +0 -141
- package/src/edit-syntax-validate.ts +0 -65
- package/src/edit.ts +0 -686
- package/src/file-map.ts +0 -17
- package/src/fs-error.ts +0 -33
- package/src/grep-budget.ts +0 -35
- package/src/grep-output.ts +0 -181
- package/src/grep-render-helpers.ts +0 -77
- package/src/grep-symbol-scope.ts +0 -184
- package/src/grep.ts +0 -608
- package/src/hashline.ts +0 -736
- package/src/hover.ts +0 -140
- package/src/path-utils.ts +0 -24
- package/src/pending-diff-preview.ts +0 -269
- package/src/read-local-bundle.ts +0 -75
- package/src/read-output.ts +0 -186
- package/src/read-render-helpers.ts +0 -104
- package/src/read.ts +0 -661
- package/src/readseek/constants.ts +0 -17
- package/src/readseek/enums.ts +0 -38
- package/src/readseek/formatter.ts +0 -350
- package/src/readseek/symbol-error-format.ts +0 -18
- package/src/readseek/symbol-lookup.ts +0 -240
- package/src/readseek/symbol-tree.ts +0 -21
- package/src/readseek/types.ts +0 -49
- package/src/readseek-client.ts +0 -1250
- package/src/readseek-params.ts +0 -39
- package/src/readseek-settings.ts +0 -263
- package/src/readseek-value.ts +0 -184
- package/src/refs-output.ts +0 -47
- package/src/refs.ts +0 -161
- package/src/register-tool.ts +0 -29
- package/src/rename.ts +0 -182
- package/src/replace-symbol.ts +0 -77
- package/src/runtime.ts +0 -3
- package/src/search-output.ts +0 -50
- package/src/search.ts +0 -214
- package/src/session-anchors.ts +0 -34
- package/src/stat-search-path.ts +0 -29
- package/src/syntax-validate-mode.ts +0 -15
- package/src/tool-prompt-metadata.ts +0 -107
- package/src/tool-types.ts +0 -12
- package/src/tui-diff-component.ts +0 -108
- package/src/tui-diff-renderer.ts +0 -92
- package/src/tui-render-utils.ts +0 -286
- package/src/view.ts +0 -158
- package/src/write.ts +0 -357
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
|
|
3
|
-
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
|
|
5
|
-
const REPLACEABLE_TOOL_GUIDELINES: Record<string, { readSeekName: string; builtInName: string; benefit: string }> = {
|
|
6
|
-
"read.md": {
|
|
7
|
-
readSeekName: "readSeek_read",
|
|
8
|
-
builtInName: "read",
|
|
9
|
-
benefit: "it provides LINE:HASH anchors for safe edits.",
|
|
10
|
-
},
|
|
11
|
-
"edit.md": {
|
|
12
|
-
readSeekName: "readSeek_edit",
|
|
13
|
-
builtInName: "edit",
|
|
14
|
-
benefit: "it verifies fresh LINE:HASH anchors.",
|
|
15
|
-
},
|
|
16
|
-
"grep.md": {
|
|
17
|
-
readSeekName: "readSeek_grep",
|
|
18
|
-
builtInName: "grep",
|
|
19
|
-
benefit: "it returns edit-ready anchors.",
|
|
20
|
-
},
|
|
21
|
-
"write.md": {
|
|
22
|
-
readSeekName: "readSeek_write",
|
|
23
|
-
builtInName: "write",
|
|
24
|
-
benefit: "it returns LINE:HASH anchors.",
|
|
25
|
-
},
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const COMPACT_GUIDELINES: Record<string, string[]> = {
|
|
29
|
-
"read.md": [
|
|
30
|
-
"Use readSeek_read map or symbol mode to inspect large code files without reading them in full.",
|
|
31
|
-
],
|
|
32
|
-
"edit.md": [
|
|
33
|
-
"With readSeek_edit, prefer set_line, replace_lines, and insert_after; use replace only when anchors are impractical.",
|
|
34
|
-
],
|
|
35
|
-
"grep.md": [
|
|
36
|
-
"Use readSeek_grep summary mode for broad count/file discovery before narrowing.",
|
|
37
|
-
],
|
|
38
|
-
"write.md": [
|
|
39
|
-
"Use anchored edits rather than readSeek_write for small changes or appends to existing files.",
|
|
40
|
-
],
|
|
41
|
-
"search.md": [
|
|
42
|
-
"Use readSeek_search for syntax-aware code shapes; use readSeek_grep for plain text.",
|
|
43
|
-
],
|
|
44
|
-
"refs.md": [
|
|
45
|
-
"Use readSeek_refs to find every usage of an identifier before renaming or deleting it.",
|
|
46
|
-
"Use readSeek_refs with scope plus line/column to follow a specific binding instead of every same-named identifier.",
|
|
47
|
-
],
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
interface ToolPromptMetadata {
|
|
51
|
-
description: string;
|
|
52
|
-
promptSnippet: string;
|
|
53
|
-
promptGuidelines: string[];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function loadPrompt(promptUrl: URL): string {
|
|
57
|
-
return readFileSync(promptUrl, "utf-8")
|
|
58
|
-
.replaceAll("{{DEFAULT_MAX_LINES}}", String(DEFAULT_MAX_LINES))
|
|
59
|
-
.replaceAll("{{DEFAULT_MAX_BYTES}}", formatSize(DEFAULT_MAX_BYTES))
|
|
60
|
-
.trim();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function firstPromptParagraph(prompt: string): string {
|
|
64
|
-
return prompt.split(/\n\s*\n/, 1)[0]?.trim() ?? prompt;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function promptFileName(promptUrl: URL): string {
|
|
68
|
-
return promptUrl.pathname.split("/").pop() ?? "";
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function rewriteToolAliases(value: string, toolAliases: Readonly<Record<string, string>> | undefined): string {
|
|
72
|
-
if (!toolAliases) return value;
|
|
73
|
-
return Object.entries(toolAliases).reduce(
|
|
74
|
-
(rewritten, [canonicalName, registeredName]) => rewritten.replaceAll(canonicalName, registeredName),
|
|
75
|
-
value,
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function defineToolPromptMetadata(options: {
|
|
80
|
-
promptUrl: URL;
|
|
81
|
-
promptSnippet: string;
|
|
82
|
-
registeredName?: string;
|
|
83
|
-
toolAliases?: Readonly<Record<string, string>>;
|
|
84
|
-
}): ToolPromptMetadata {
|
|
85
|
-
const prompt = loadPrompt(options.promptUrl);
|
|
86
|
-
const fileName = promptFileName(options.promptUrl);
|
|
87
|
-
const replaceable = REPLACEABLE_TOOL_GUIDELINES[fileName];
|
|
88
|
-
const registeredName = options.registeredName ?? replaceable?.readSeekName;
|
|
89
|
-
const preferenceGuideline = replaceable && registeredName
|
|
90
|
-
? registeredName === replaceable.readSeekName
|
|
91
|
-
? `Prefer ${registeredName} over ${replaceable.builtInName} when both are available; ${replaceable.benefit}`
|
|
92
|
-
: `Use ${registeredName}; ${replaceable.benefit}`
|
|
93
|
-
: undefined;
|
|
94
|
-
return {
|
|
95
|
-
description: rewriteToolAliases(firstPromptParagraph(prompt), options.toolAliases),
|
|
96
|
-
promptSnippet: rewriteToolAliases(options.promptSnippet, options.toolAliases),
|
|
97
|
-
promptGuidelines: [
|
|
98
|
-
...(preferenceGuideline ? [preferenceGuideline] : []),
|
|
99
|
-
...(COMPACT_GUIDELINES[fileName] ?? []).map((guideline) =>
|
|
100
|
-
rewriteToolAliases(
|
|
101
|
-
registeredName && replaceable ? guideline.replaceAll(replaceable.readSeekName, registeredName) : guideline,
|
|
102
|
-
options.toolAliases,
|
|
103
|
-
),
|
|
104
|
-
),
|
|
105
|
-
],
|
|
106
|
-
};
|
|
107
|
-
}
|
package/src/tool-types.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Callback used when a tool returns fresh anchors for a file path.
|
|
3
|
-
*/
|
|
4
|
-
export type FileAnchoredCallback = (absolutePath: string) => void;
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Predicate used by mutating tools to check whether a file has session-fresh anchors.
|
|
8
|
-
*/
|
|
9
|
-
export type FreshAnchorsPredicate = (absolutePath: string) => boolean;
|
|
10
|
-
|
|
11
|
-
/** Callback used after a tool persists a file mutation. */
|
|
12
|
-
export type FileMutatedCallback = (absolutePath: string) => void;
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import type { Component } from "@earendil-works/pi-tui";
|
|
2
|
-
import { Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import type { DiffData } from "./diff-data.js";
|
|
4
|
-
import { renderTuiDiff } from "./tui-diff-renderer.js";
|
|
5
|
-
import { clampLineToWidth, normalizeWidth, type RendererTheme } from "./tui-render-utils.js";
|
|
6
|
-
|
|
7
|
-
export interface DiffPreviewComponentOptions {
|
|
8
|
-
/**
|
|
9
|
-
* Lines rendered before the diff body (e.g. tool summary, "pending edit"
|
|
10
|
-
* header). Each entry is one logical line; the component is responsible
|
|
11
|
-
* for clamping to the render-time width.
|
|
12
|
-
*/
|
|
13
|
-
prefixLines?: string[];
|
|
14
|
-
/**
|
|
15
|
-
* Lines rendered after the diff body (e.g. trailing hints). Currently
|
|
16
|
-
* unused but kept for symmetry.
|
|
17
|
-
*/
|
|
18
|
-
suffixLines?: string[];
|
|
19
|
-
/** Diff body data. */
|
|
20
|
-
diffData: DiffData;
|
|
21
|
-
/** Theme passed through to renderTuiDiff for color tinting. */
|
|
22
|
-
theme: RendererTheme;
|
|
23
|
-
/** Whether the diff body should render in expanded form. */
|
|
24
|
-
expanded: boolean;
|
|
25
|
-
/**
|
|
26
|
-
* Optional fallback width used when render() receives an invalid width
|
|
27
|
-
* (zero, negative, NaN). Defaults to 80.
|
|
28
|
-
*/
|
|
29
|
-
fallbackWidth?: number;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Self-rendering component for a tool's diff preview that defers wrapping
|
|
34
|
-
* decisions to the TUI render pass.
|
|
35
|
-
*
|
|
36
|
-
* `renderTuiDiff` (and the underlying `wrapWithHangingIndent` helper) need
|
|
37
|
-
* the real viewport width to choose between split / unified / compact / summary
|
|
38
|
-
* modes and to wrap long content rows with prefix-aligned continuation lines.
|
|
39
|
-
* Pi's `ToolRenderContext` does NOT carry the render-time width, so any
|
|
40
|
-
* pre-baked output is forced to assume a fallback (80 columns). This
|
|
41
|
-
* component holds the source data instead, then renders at the width pi's TUI
|
|
42
|
-
* actually passes in.
|
|
43
|
-
*/
|
|
44
|
-
export class DiffPreviewComponent implements Component {
|
|
45
|
-
private options: DiffPreviewComponentOptions;
|
|
46
|
-
private cachedWidth: number | undefined;
|
|
47
|
-
private cachedLines: string[] | undefined;
|
|
48
|
-
|
|
49
|
-
constructor(options: DiffPreviewComponentOptions) {
|
|
50
|
-
this.options = options;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
update(options: DiffPreviewComponentOptions): void {
|
|
54
|
-
this.options = options;
|
|
55
|
-
this.invalidate();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
invalidate(): void {
|
|
59
|
-
this.cachedWidth = undefined;
|
|
60
|
-
this.cachedLines = undefined;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
render(width: number): string[] {
|
|
64
|
-
const normalized = normalizeWidth(width, this.options.fallbackWidth ?? 80);
|
|
65
|
-
if (this.cachedLines && this.cachedWidth === normalized) return this.cachedLines;
|
|
66
|
-
const lines: string[] = [];
|
|
67
|
-
for (const prefix of this.options.prefixLines ?? []) {
|
|
68
|
-
for (const line of prefix.split("\n")) lines.push(clampLineToWidth(line, normalized));
|
|
69
|
-
}
|
|
70
|
-
const body = renderTuiDiff({
|
|
71
|
-
diffData: this.options.diffData,
|
|
72
|
-
width: normalized,
|
|
73
|
-
theme: this.options.theme,
|
|
74
|
-
expanded: this.options.expanded,
|
|
75
|
-
});
|
|
76
|
-
for (const line of body.lines) lines.push(line);
|
|
77
|
-
for (const suffix of this.options.suffixLines ?? []) {
|
|
78
|
-
for (const line of suffix.split("\n")) lines.push(clampLineToWidth(line, normalized));
|
|
79
|
-
}
|
|
80
|
-
// Final safety clamp: each rendered line must fit within the viewport.
|
|
81
|
-
const clamped = lines.map((line) => (visibleWidth(line) <= normalized ? line : clampLineToWidth(line, normalized)));
|
|
82
|
-
this.cachedLines = clamped;
|
|
83
|
-
this.cachedWidth = normalized;
|
|
84
|
-
return clamped;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Reuse the prior render tick's {@link DiffPreviewComponent} when present, else
|
|
90
|
-
* construct one, then apply {@link options}. Keeps a streaming diff preview
|
|
91
|
-
* stable across ticks instead of remounting a fresh component each frame.
|
|
92
|
-
*/
|
|
93
|
-
export function upsertDiffComponent(lastComponent: unknown, options: DiffPreviewComponentOptions): DiffPreviewComponent {
|
|
94
|
-
const component = lastComponent instanceof DiffPreviewComponent ? lastComponent : new DiffPreviewComponent(options);
|
|
95
|
-
component.update(options);
|
|
96
|
-
return component;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Reuse the prior render tick's text component when it is not a
|
|
101
|
-
* {@link DiffPreviewComponent}, else construct a fresh {@link Text}, then set
|
|
102
|
-
* its content to {@link text}.
|
|
103
|
-
*/
|
|
104
|
-
export function upsertTextComponent(lastComponent: any, text: string): Text {
|
|
105
|
-
const component = lastComponent && !(lastComponent instanceof DiffPreviewComponent) ? lastComponent : new Text("", 0, 0);
|
|
106
|
-
component.setText(text);
|
|
107
|
-
return component;
|
|
108
|
-
}
|
package/src/tui-diff-renderer.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import type { DiffData, DiffEntry, DiffSpan } from "./diff-data.js";
|
|
3
|
-
import { clampLineToWidth, clampLinesToWidth, normalizeWidth, wrapWithHangingIndent, type RendererTheme } from "./tui-render-utils.js";
|
|
4
|
-
|
|
5
|
-
export type TuiDiffMode = "split" | "unified" | "compact" | "summary";
|
|
6
|
-
export type RenderTuiDiffInput = { diffData: DiffData; width: number; theme: RendererTheme; expanded: boolean };
|
|
7
|
-
export type RenderTuiDiffOutput = { mode: TuiDiffMode; width: number; lines: string[] };
|
|
8
|
-
|
|
9
|
-
function hasOldSide(data: DiffData): boolean { return data.entries.some((e) => e.kind === "remove" || e.kind === "context"); }
|
|
10
|
-
function chooseMode(width: number, data: DiffData): TuiDiffMode {
|
|
11
|
-
if (width < 24) return "summary";
|
|
12
|
-
if (width < 50) return "compact";
|
|
13
|
-
// Split mode wastes half the pane on pure-add diffs (pending creates,
|
|
14
|
-
// write to a new file) so fall back to unified when there is no old side.
|
|
15
|
-
if (width >= 100 && hasOldSide(data)) return "split";
|
|
16
|
-
return "unified";
|
|
17
|
-
}
|
|
18
|
-
function hunkCount(data: DiffData): number { return Math.max(1, data.blockRanges?.length ?? (data.entries.some((e) => e.kind === "add" || e.kind === "remove") ? 1 : 0)); }
|
|
19
|
-
function compactHeader(data: DiffData): string { return `↳ diff +${data.stats.added} -${data.stats.removed}`; }
|
|
20
|
-
function header(data: DiffData, mode: TuiDiffMode, width: number): string {
|
|
21
|
-
if (mode === "summary" && width <= 10) return `↳ diff +${data.stats.added}`;
|
|
22
|
-
const full = `${compactHeader(data)} • ${hunkCount(data)} hunk • 1 file • ${mode === "split" ? "split" : "unified"}`;
|
|
23
|
-
return visibleWidth(full) <= width ? full : clampLineToWidth(compactHeader(data), width);
|
|
24
|
-
}
|
|
25
|
-
function lineNo(entry: DiffEntry): string { return String(entry.kind === "add" ? entry.newLine : entry.kind === "remove" ? entry.oldLine : entry.kind === "context" ? entry.newLine : ""); }
|
|
26
|
-
function gutterMarker(entry: DiffEntry): string { return entry.kind === "add" ? "+" : entry.kind === "remove" ? "-" : " "; }
|
|
27
|
-
function textOf(entry: DiffEntry): string { return "text" in entry ? entry.text : ""; }
|
|
28
|
-
function padRightVisual(line: string, width: number): string { const visible = visibleWidth(line); return visible >= width ? line : line + " ".repeat(width - visible); }
|
|
29
|
-
function tint(theme: RendererTheme, entry: DiffEntry, text: string): string { return entry.kind === "add" ? theme.fg("success", text) : entry.kind === "remove" ? theme.fg("error", text) : theme.fg("toolOutput", text); }
|
|
30
|
-
function spans(theme: RendererTheme, spans: DiffSpan[] | undefined, fallback: string): string { return spans?.map((s) => s.kind === "add" ? theme.fg("success", s.text) : s.kind === "remove" ? theme.fg("error", s.text) : s.text).join("") ?? fallback; }
|
|
31
|
-
function inlineText(input: RenderTuiDiffInput, index: number, entry: DiffEntry): string {
|
|
32
|
-
const pair = input.diffData.inlineDiffs?.find((d) => entry.kind === "remove" ? d.removeLineIndex === index : entry.kind === "add" ? d.addLineIndex === index : false);
|
|
33
|
-
if (!pair) return textOf(entry);
|
|
34
|
-
return entry.kind === "remove" ? spans(input.theme, pair.removeSpans, textOf(entry)) : entry.kind === "add" ? spans(input.theme, pair.addSpans, textOf(entry)) : textOf(entry);
|
|
35
|
-
}
|
|
36
|
-
function unifiedRows(input: RenderTuiDiffInput, width: number): string[] {
|
|
37
|
-
const rows: string[] = [];
|
|
38
|
-
for (const [i, e] of input.diffData.entries.entries()) {
|
|
39
|
-
if (e.kind === "meta") continue;
|
|
40
|
-
const prefix = `▌${gutterMarker(e)} ${lineNo(e)} │ `;
|
|
41
|
-
const tinted = wrapWithHangingIndent(prefix, inlineText(input, i, e), width, { tint: (text) => tint(input.theme, e, text) });
|
|
42
|
-
rows.push(...tinted);
|
|
43
|
-
}
|
|
44
|
-
return rows;
|
|
45
|
-
}
|
|
46
|
-
function compactRows(input: RenderTuiDiffInput, width: number): string[] {
|
|
47
|
-
const rows: string[] = [];
|
|
48
|
-
for (const [i, e] of input.diffData.entries.entries()) {
|
|
49
|
-
if (e.kind !== "add" && e.kind !== "remove") continue;
|
|
50
|
-
const prefix = `▌${gutterMarker(e)} ${lineNo(e)} `;
|
|
51
|
-
const tinted = wrapWithHangingIndent(prefix, inlineText(input, i, e), width, { tint: (text) => tint(input.theme, e, text) });
|
|
52
|
-
rows.push(...tinted);
|
|
53
|
-
}
|
|
54
|
-
return rows;
|
|
55
|
-
}
|
|
56
|
-
function splitRows(input: RenderTuiDiffInput, width: number): string[] {
|
|
57
|
-
const pane = Math.max(10, Math.floor((width - 3) / 2));
|
|
58
|
-
const rows = [`${padRightVisual("old", pane)} │ new`];
|
|
59
|
-
const blankPane = " ".repeat(pane);
|
|
60
|
-
for (const [i, e] of input.diffData.entries.entries()) {
|
|
61
|
-
if (e.kind === "remove") {
|
|
62
|
-
const left = wrapWithHangingIndent(`▌- ${e.oldLine} │ `, inlineText(input, i, e), pane, { tint: (text) => tint(input.theme, e, text) });
|
|
63
|
-
for (const line of left) rows.push(`${padRightVisual(line, pane)} │ ${blankPane}`);
|
|
64
|
-
} else if (e.kind === "add") {
|
|
65
|
-
const right = wrapWithHangingIndent(`▌+ ${e.newLine} │ `, inlineText(input, i, e), pane, { tint: (text) => tint(input.theme, e, text) });
|
|
66
|
-
for (const line of right) rows.push(`${blankPane} │ ${line}`);
|
|
67
|
-
} else if (e.kind === "context") {
|
|
68
|
-
const left = wrapWithHangingIndent(`▌ ${e.oldLine} │ `, e.text, pane, { tint: (text) => tint(input.theme, e, text) });
|
|
69
|
-
const right = wrapWithHangingIndent(`▌ ${e.newLine} │ `, e.text, pane, { tint: (text) => tint(input.theme, e, text) });
|
|
70
|
-
const maxLen = Math.max(left.length, right.length);
|
|
71
|
-
for (let k = 0; k < maxLen; k++) {
|
|
72
|
-
const l = left[k] ?? blankPane;
|
|
73
|
-
const r = right[k] ?? blankPane;
|
|
74
|
-
rows.push(`${padRightVisual(l, pane)} │ ${r}`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return rows;
|
|
79
|
-
}
|
|
80
|
-
function hiddenHint(hiddenLines: number, hiddenHunks: number, width: number): string {
|
|
81
|
-
const forms = [`… (${hiddenLines} more diff lines • ${hiddenHunks} more hunk${hiddenHunks === 1 ? "" : "s"} • Ctrl+O to expand)`, `… (${hiddenLines} more lines • ${hiddenHunks} hunks)`, `… (+${hiddenLines} • +${hiddenHunks}h)`, "…"];
|
|
82
|
-
return forms.find((f) => visibleWidth(f) <= width) ?? "…";
|
|
83
|
-
}
|
|
84
|
-
export function renderTuiDiff(input: RenderTuiDiffInput): RenderTuiDiffOutput {
|
|
85
|
-
const width = normalizeWidth(input.width);
|
|
86
|
-
const mode = chooseMode(width, input.diffData);
|
|
87
|
-
const lines = [header(input.diffData, mode, width)];
|
|
88
|
-
if (!input.expanded) return { mode, width, lines: clampLinesToWidth([...lines, hiddenHint(input.diffData.entries.length, hunkCount(input.diffData), width)], width) };
|
|
89
|
-
if (mode === "summary") return { mode, width, lines: clampLinesToWidth(lines, width) };
|
|
90
|
-
const rows = mode === "split" ? splitRows(input, width) : mode === "compact" ? compactRows(input, width) : unifiedRows(input, width);
|
|
91
|
-
return { mode, width, lines: [...lines, ...rows] };
|
|
92
|
-
}
|
package/src/tui-render-utils.ts
DELETED
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
import { relative } from "node:path";
|
|
2
|
-
import { pathToFileURL } from "node:url";
|
|
3
|
-
|
|
4
|
-
import { getCapabilities, hyperlink, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
5
|
-
|
|
6
|
-
import { resolveToCwd } from "./path-utils.js";
|
|
7
|
-
|
|
8
|
-
export const SUMMARY_PREFIX = "↳";
|
|
9
|
-
export const EXPAND_HINT = " • Ctrl+O to expand";
|
|
10
|
-
|
|
11
|
-
export type RendererTheme = {
|
|
12
|
-
fg(style: string, text: string): string;
|
|
13
|
-
bold(text: string): string;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export function renderToolLabel(theme: RendererTheme, label: string): string {
|
|
17
|
-
const boldFn = typeof theme.bold === "function" ? theme.bold.bind(theme) : (text: string) => text;
|
|
18
|
-
return theme.fg("toolTitle", boldFn(label));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function linkToolPath(styledText: string, rawPath: string, cwd: string): string {
|
|
22
|
-
try {
|
|
23
|
-
if (!getCapabilities().hyperlinks) return styledText;
|
|
24
|
-
const absolutePath = resolveToCwd(rawPath, cwd);
|
|
25
|
-
return hyperlink(styledText, pathToFileURL(absolutePath).href);
|
|
26
|
-
} catch {
|
|
27
|
-
return styledText;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function linkedPathLine(
|
|
32
|
-
theme: RendererTheme,
|
|
33
|
-
rawPath: string,
|
|
34
|
-
displayPath: string,
|
|
35
|
-
cwd: string,
|
|
36
|
-
suffix: string,
|
|
37
|
-
width: number | undefined,
|
|
38
|
-
): string {
|
|
39
|
-
const pathWidth = width === undefined ? undefined : Math.max(1, width - 2 - visibleWidth(suffix));
|
|
40
|
-
const visiblePath = pathWidth === undefined ? displayPath : truncateToWidth(displayPath, pathWidth);
|
|
41
|
-
const linkedPath = linkToolPath(theme.fg("dim", visiblePath), rawPath, cwd);
|
|
42
|
-
return ` ${linkedPath}${theme.fg("dim", suffix)}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function appendExpandHint(text: string, hidden: boolean): string {
|
|
46
|
-
return hidden ? `${text}${EXPAND_HINT}` : text;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function summaryLine(
|
|
50
|
-
summary: string,
|
|
51
|
-
options: { hidden?: boolean; theme?: RendererTheme; style?: string } = {},
|
|
52
|
-
): string {
|
|
53
|
-
const prefix = options.theme ? options.theme.fg(options.style ?? "dim", SUMMARY_PREFIX) : SUMMARY_PREFIX;
|
|
54
|
-
return appendExpandHint(`${prefix} ${summary}`, !!options.hidden);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function isRendererExpanded(options?: { expanded?: boolean }, context?: { expanded?: boolean }): boolean {
|
|
58
|
-
return context?.expanded ?? options?.expanded ?? false;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface RenderResultContext {
|
|
62
|
-
isPartial: boolean;
|
|
63
|
-
isError: boolean;
|
|
64
|
-
expanded: boolean;
|
|
65
|
-
width: number | undefined;
|
|
66
|
-
cwd: string;
|
|
67
|
-
context: Record<string, any>;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Resolve the shared render context for a tool's `renderResult`, which may
|
|
72
|
-
* receive its context either as a trailing `rest[0]` argument or folded into
|
|
73
|
-
* `options`. Returns the common flags plus the raw context object for callers
|
|
74
|
-
* that need fields beyond the common set (e.g. `lastComponent`).
|
|
75
|
-
*/
|
|
76
|
-
export function resolveRenderResultContext(options: any, rest: any[]): RenderResultContext {
|
|
77
|
-
const context = rest[0] ?? options ?? {};
|
|
78
|
-
return {
|
|
79
|
-
isPartial: context.isPartial ?? options?.isPartial ?? false,
|
|
80
|
-
isError: context.isError ?? false,
|
|
81
|
-
expanded: isRendererExpanded(options, context),
|
|
82
|
-
width: context.width ?? options?.width,
|
|
83
|
-
cwd: context.cwd ?? process.cwd(),
|
|
84
|
-
context,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function normalizeWidth(width: unknown, fallback = 80): number {
|
|
89
|
-
return typeof width === "number" && Number.isFinite(width) && width > 0 ? Math.floor(width) : fallback;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function clampLineToWidth(line: string, width: number | undefined): string {
|
|
93
|
-
if (width === undefined || width === null) return line;
|
|
94
|
-
const normalized = normalizeWidth(width);
|
|
95
|
-
return visibleWidth(line) <= normalized ? line : truncateToWidth(line, normalized);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function clampLinesToWidth(lines: string[], width: number | undefined): string[] {
|
|
99
|
-
if (width === undefined || width === null) return lines;
|
|
100
|
-
return lines.map((line) => clampLineToWidth(line, width));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export interface WrapWithHangingIndentOptions {
|
|
104
|
-
/** Optional transform applied to each produced line (e.g. theme tinting). */
|
|
105
|
-
tint?: (text: string) => string;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Wrap a single visual row that has a leading prefix (gutter, line number,
|
|
110
|
-
* separator) such that continuation lines are indented to align with the
|
|
111
|
-
* content column. Each produced line is clamped to `width`. If `tint` is
|
|
112
|
-
* provided, it is applied to each output line so theme styling extends across
|
|
113
|
-
* wrapped rows without leaking the prefix into the colored span.
|
|
114
|
-
*/
|
|
115
|
-
export function wrapWithHangingIndent(
|
|
116
|
-
prefix: string,
|
|
117
|
-
content: string,
|
|
118
|
-
width: number | undefined,
|
|
119
|
-
options: WrapWithHangingIndentOptions = {},
|
|
120
|
-
): string[] {
|
|
121
|
-
const tint = options.tint ?? ((text: string) => text);
|
|
122
|
-
if (width === undefined || width === null) return [tint(prefix + content)];
|
|
123
|
-
const normalized = normalizeWidth(width);
|
|
124
|
-
const combined = prefix + content;
|
|
125
|
-
if (visibleWidth(combined) <= normalized) return [tint(combined)];
|
|
126
|
-
const prefixWidth = visibleWidth(prefix);
|
|
127
|
-
const contentWidth = Math.max(1, normalized - prefixWidth);
|
|
128
|
-
const wrapped = wrapTextWithAnsi(content, contentWidth);
|
|
129
|
-
if (wrapped.length === 0) return [tint(clampLineToWidth(prefix, normalized))];
|
|
130
|
-
const indent = " ".repeat(prefixWidth);
|
|
131
|
-
return wrapped.map((line, index) =>
|
|
132
|
-
tint(clampLineToWidth(index === 0 ? prefix + line : indent + line, normalized)),
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
const HASHLINE_CONTENT_RE = /^(\d+:[0-9a-fA-F]+\|)(.*)$/;
|
|
136
|
-
|
|
137
|
-
export function wrapReadHashlinesForWidth(text: string, width: number | undefined): string {
|
|
138
|
-
if (width === undefined || width === null) return text;
|
|
139
|
-
const normalized = normalizeWidth(width);
|
|
140
|
-
const output: string[] = [];
|
|
141
|
-
for (const line of text.split("\n")) {
|
|
142
|
-
const match = line.match(HASHLINE_CONTENT_RE);
|
|
143
|
-
if (!match) {
|
|
144
|
-
output.push(line);
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
if (visibleWidth(line) <= normalized) {
|
|
148
|
-
output.push(line);
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const prefix = match[1]!;
|
|
153
|
-
const content = match[2] ?? "";
|
|
154
|
-
const prefixWidth = visibleWidth(prefix);
|
|
155
|
-
const contentWidth = Math.max(1, normalized - prefixWidth);
|
|
156
|
-
const wrappedContent = wrapTextWithAnsi(content, contentWidth).map((wrapped) => clampLineToWidth(wrapped, contentWidth));
|
|
157
|
-
if (wrappedContent.length === 0) {
|
|
158
|
-
output.push(clampLineToWidth(prefix, normalized));
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
output.push(clampLineToWidth(prefix + wrappedContent[0], normalized));
|
|
162
|
-
const indent = " ".repeat(prefixWidth);
|
|
163
|
-
for (const continuation of wrappedContent.slice(1)) {
|
|
164
|
-
output.push(clampLineToWidth(indent + continuation, normalized));
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
return output.join("\n");
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const wrappedHashlinesCache = new WeakMap<object, { text: string; width: number | undefined; wrapped: string }>();
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Memoized {@link wrapReadHashlinesForWidth} for render paths that re-run on
|
|
174
|
-
* every TUI frame: the wrapped output is cached per result content object so
|
|
175
|
-
* an expanded read result is only re-wrapped when its text or the terminal
|
|
176
|
-
* width changes.
|
|
177
|
-
*/
|
|
178
|
-
export function wrapReadHashlinesForWidthCached(
|
|
179
|
-
cacheKey: object,
|
|
180
|
-
text: string,
|
|
181
|
-
width: number | undefined,
|
|
182
|
-
): string {
|
|
183
|
-
const cached = wrappedHashlinesCache.get(cacheKey);
|
|
184
|
-
if (cached && cached.text === text && cached.width === width) return cached.wrapped;
|
|
185
|
-
const wrapped = wrapReadHashlinesForWidth(text, width);
|
|
186
|
-
wrappedHashlinesCache.set(cacheKey, { text, width, wrapped });
|
|
187
|
-
return wrapped;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Render the `isPartial` placeholder line shared by every tool's `renderResult`.
|
|
192
|
-
*/
|
|
193
|
-
export function renderPendingResult(pendingLabel: string, width: number | undefined, theme?: RendererTheme): Text {
|
|
194
|
-
return new Text(clampLinesToWidth([summaryLine(pendingLabel, { theme, style: "muted" })], width).join("\n"), 0, 0);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Render a tool result error: the first line of `textContent`, or its full body
|
|
199
|
-
* when expanded, prefixed and clamped to width. Tools pass their own `fallback`
|
|
200
|
-
* for the empty-content case.
|
|
201
|
-
*/
|
|
202
|
-
export function renderErrorResult(
|
|
203
|
-
textContent: string,
|
|
204
|
-
options: { expanded: boolean; width: number | undefined; fallback?: string; theme?: RendererTheme },
|
|
205
|
-
): Text {
|
|
206
|
-
const firstLine = textContent.split("\n")[0] || (options.fallback ?? "Error");
|
|
207
|
-
const body = options.expanded && textContent ? textContent : firstLine;
|
|
208
|
-
return new Text(
|
|
209
|
-
clampLinesToWidth(summaryLine(body, { theme: options.theme, style: "error" }).split("\n"), options.width).join("\n"),
|
|
210
|
-
0,
|
|
211
|
-
0,
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export interface AnchoredFilesLabels {
|
|
216
|
-
pendingLabel: string;
|
|
217
|
-
emptyLabel: string;
|
|
218
|
-
unitSingular: string;
|
|
219
|
-
unitPlural: string;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Render the call line shared by the readseek search tools (search, refs): a
|
|
224
|
-
* tool label, an accent term, the search path, an optional language hint, and
|
|
225
|
-
* the active boolean flags. Falsy {@link opts.flags} entries are dropped.
|
|
226
|
-
*/
|
|
227
|
-
export function renderReadSeekSearchCall(
|
|
228
|
-
args: { path?: string; lang?: string },
|
|
229
|
-
theme: RendererTheme,
|
|
230
|
-
rest: any[],
|
|
231
|
-
opts: { label: string; accent: string; flags: Array<string | false | undefined> },
|
|
232
|
-
): Text {
|
|
233
|
-
const context = rest[0] ?? {};
|
|
234
|
-
let text = `${renderToolLabel(theme, opts.label)} ${theme.fg("accent", opts.accent)}`;
|
|
235
|
-
text += theme.fg("dim", ` in ${args.path ?? "."}`);
|
|
236
|
-
if (args.lang) text += theme.fg("dim", ` (${args.lang})`);
|
|
237
|
-
const flags = opts.flags.filter(Boolean);
|
|
238
|
-
if (flags.length > 0) text += theme.fg("dim", ` [${flags.join(",")}]`);
|
|
239
|
-
return new Text(clampLineToWidth(text, context.width), 0, 0);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Render the result summary shared by the anchored-files search tools (search,
|
|
244
|
-
* refs): a pending line, an error first-line/expanded body, an empty-result
|
|
245
|
-
* line, or a `<count> <unit> in <n> files` summary with an expandable per-file
|
|
246
|
-
* list. The four call-site differences are supplied via {@link labels}.
|
|
247
|
-
*/
|
|
248
|
-
export function renderAnchoredFilesResult(
|
|
249
|
-
result: any,
|
|
250
|
-
options: any,
|
|
251
|
-
theme: RendererTheme,
|
|
252
|
-
rest: any[],
|
|
253
|
-
labels: AnchoredFilesLabels,
|
|
254
|
-
): Text {
|
|
255
|
-
const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
|
|
256
|
-
|
|
257
|
-
if (isPartial) return renderPendingResult(labels.pendingLabel, width, theme);
|
|
258
|
-
|
|
259
|
-
const content = result.content?.[0];
|
|
260
|
-
const textContent = content?.type === "text" ? content.text : "";
|
|
261
|
-
if (isError || result.isError) return renderErrorResult(textContent, { expanded, width, theme });
|
|
262
|
-
|
|
263
|
-
const readSeekValue = (result.details as any)?.readSeekValue as
|
|
264
|
-
| { files: Array<{ path: string; lines: any[] }> }
|
|
265
|
-
| undefined;
|
|
266
|
-
const files = readSeekValue?.files ?? [];
|
|
267
|
-
if (files.length === 0) return new Text(summaryLine(labels.emptyLabel, { theme, style: "dim" }), 0, 0);
|
|
268
|
-
|
|
269
|
-
const fileCount = files.length;
|
|
270
|
-
const total = files.reduce((sum: number, f: any) => sum + f.lines.length, 0);
|
|
271
|
-
const unitWord = total === 1 ? labels.unitSingular : labels.unitPlural;
|
|
272
|
-
const fileWord = fileCount === 1 ? "file" : "files";
|
|
273
|
-
let text = summaryLine(`${total} ${unitWord} in ${fileCount} ${fileWord}`, {
|
|
274
|
-
hidden: !expanded,
|
|
275
|
-
theme,
|
|
276
|
-
style: "success",
|
|
277
|
-
});
|
|
278
|
-
if (expanded) {
|
|
279
|
-
for (const file of files.slice(0, 20)) {
|
|
280
|
-
const display = relative(cwd, file.path) || file.path;
|
|
281
|
-
text += `\n${linkedPathLine(theme, file.path, display, cwd, ` (${file.lines.length})`, width)}`;
|
|
282
|
-
}
|
|
283
|
-
if (files.length > 20) text += "\n" + theme.fg("muted", ` … and ${files.length - 20} more files`);
|
|
284
|
-
}
|
|
285
|
-
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
286
|
-
}
|