pi-readseek 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +41 -0
- package/index.ts +142 -0
- package/package.json +73 -0
- package/prompts/edit.md +113 -0
- package/prompts/find.md +19 -0
- package/prompts/grep.md +26 -0
- package/prompts/ls.md +11 -0
- package/prompts/read.md +33 -0
- package/prompts/sg.md +25 -0
- package/prompts/write.md +46 -0
- package/src/binary-detect.ts +22 -0
- package/src/binary-resolution.ts +77 -0
- package/src/coerce-obvious-int.ts +39 -0
- package/src/context-application.ts +70 -0
- package/src/context-hygiene.ts +503 -0
- package/src/diff-data.ts +303 -0
- package/src/doom-loop-suggestions.ts +42 -0
- package/src/doom-loop.ts +216 -0
- package/src/edit-classify.ts +190 -0
- package/src/edit-diff.ts +354 -0
- package/src/edit-output.ts +107 -0
- package/src/edit-render-helpers.ts +141 -0
- package/src/edit-syntax-validate.ts +120 -0
- package/src/edit.ts +725 -0
- package/src/find-parsers.ts +89 -0
- package/src/find-stat.ts +36 -0
- package/src/find.ts +613 -0
- package/src/grep-budget.ts +79 -0
- package/src/grep-output.ts +197 -0
- package/src/grep-render-helpers.ts +77 -0
- package/src/grep-symbol-scope.ts +197 -0
- package/src/grep.ts +792 -0
- package/src/hashline.ts +747 -0
- package/src/ls.ts +293 -0
- package/src/map-cache.ts +152 -0
- package/src/path-utils.ts +24 -0
- package/src/pending-diff-preview.ts +269 -0
- package/src/persistent-map-cache.ts +251 -0
- package/src/read-local-bundle.ts +87 -0
- package/src/read-output.ts +212 -0
- package/src/read-render-helpers.ts +104 -0
- package/src/read.ts +748 -0
- package/src/readseek/constants.ts +21 -0
- package/src/readseek/enums.ts +38 -0
- package/src/readseek/formatter.ts +431 -0
- package/src/readseek/language-detect.ts +29 -0
- package/src/readseek/mapper.ts +69 -0
- package/src/readseek/parser-errors.ts +22 -0
- package/src/readseek/parser-loader.ts +83 -0
- package/src/readseek/symbol-error-format.ts +18 -0
- package/src/readseek/symbol-lookup.ts +294 -0
- package/src/readseek/types.ts +79 -0
- package/src/readseek-client.ts +343 -0
- package/src/readseek-error-codes.ts +54 -0
- package/src/readseek-settings.ts +287 -0
- package/src/readseek-value.ts +144 -0
- package/src/replace-symbol.ts +74 -0
- package/src/runtime.ts +3 -0
- package/src/sg-output.ts +88 -0
- package/src/sg.ts +308 -0
- package/src/syntax-validate-mode.ts +25 -0
- package/src/tool-prompt-metadata.ts +76 -0
- package/src/tui-diff-component.ts +86 -0
- package/src/tui-diff-renderer.ts +92 -0
- package/src/tui-render-utils.ts +129 -0
- package/src/write.ts +532 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const COMPACT_DESCRIPTIONS: Record<string, string> = {
|
|
5
|
+
"read.md": "Read text files/images by path; text has LINE:HASH anchors, images return attachments.",
|
|
6
|
+
"edit.md": "Edit existing text files using fresh LINE:HASH anchors from read, grep, search, or write.",
|
|
7
|
+
"grep.md": "Search file contents; non-summary results include LINE:HASH anchors for edits.",
|
|
8
|
+
"find.md": "Find files by glob, respecting .gitignore.",
|
|
9
|
+
"ls.md": "List one directory.",
|
|
10
|
+
"write.md": "Create or overwrite a file and return anchors.",
|
|
11
|
+
"sg.md": "Search code by AST pattern and return anchored matches.",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const COMPACT_GUIDELINES: Record<string, string[]> = {
|
|
15
|
+
"read.md": [
|
|
16
|
+
"Use read for file contents, images/screenshots, ranges, symbols, and edit anchors.",
|
|
17
|
+
"Use read for images; it returns attachments, so avoid OCR tools unless explicitly needed.",
|
|
18
|
+
],
|
|
19
|
+
"edit.md": [
|
|
20
|
+
"Use edit with fresh LINE:HASH anchors for existing files.",
|
|
21
|
+
"Use edit replace only when anchored edits are impractical.",
|
|
22
|
+
],
|
|
23
|
+
"grep.md": [
|
|
24
|
+
"Use grep for text search and edit-ready matching anchors.",
|
|
25
|
+
"Use grep summary mode when only file counts are needed.",
|
|
26
|
+
],
|
|
27
|
+
"find.md": [
|
|
28
|
+
"Use find for recursive file discovery by glob.",
|
|
29
|
+
],
|
|
30
|
+
"ls.md": [
|
|
31
|
+
"Use ls to list one directory, optionally with a glob filter.",
|
|
32
|
+
],
|
|
33
|
+
"write.md": [
|
|
34
|
+
"Use write to create files or intentionally overwrite whole files.",
|
|
35
|
+
"Use edit rather than write for small changes to existing files.",
|
|
36
|
+
],
|
|
37
|
+
"sg.md": [
|
|
38
|
+
"Use search for AST-shaped code patterns.",
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface ToolPromptMetadata {
|
|
43
|
+
description: string;
|
|
44
|
+
promptSnippet: string;
|
|
45
|
+
promptGuidelines: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadPrompt(promptUrl: URL): string {
|
|
49
|
+
return readFileSync(promptUrl, "utf-8")
|
|
50
|
+
.replaceAll("{{DEFAULT_MAX_LINES}}", String(DEFAULT_MAX_LINES))
|
|
51
|
+
.replaceAll("{{DEFAULT_MAX_BYTES}}", formatSize(DEFAULT_MAX_BYTES))
|
|
52
|
+
.trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function firstPromptParagraph(prompt: string): string {
|
|
56
|
+
return prompt.split(/\n\s*\n/, 1)[0]?.trim() ?? prompt;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function promptFileName(promptUrl: URL): string {
|
|
60
|
+
return promptUrl.pathname.split("/").pop() ?? "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function defineToolPromptMetadata(options: {
|
|
64
|
+
promptUrl: URL;
|
|
65
|
+
promptSnippet: string;
|
|
66
|
+
promptGuidelines: string[];
|
|
67
|
+
}): ToolPromptMetadata {
|
|
68
|
+
const prompt = loadPrompt(options.promptUrl);
|
|
69
|
+
const fileName = promptFileName(options.promptUrl);
|
|
70
|
+
const compactDescription = COMPACT_DESCRIPTIONS[fileName];
|
|
71
|
+
return {
|
|
72
|
+
description: compactDescription ?? firstPromptParagraph(prompt),
|
|
73
|
+
promptSnippet: options.promptSnippet,
|
|
74
|
+
promptGuidelines: COMPACT_GUIDELINES[fileName] ?? options.promptGuidelines,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import { 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
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { getCapabilities, hyperlink, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { resolveToCwd } from "./path-utils.js";
|
|
4
|
+
|
|
5
|
+
export const SUMMARY_PREFIX = "↳";
|
|
6
|
+
export const EXPAND_HINT = " • Ctrl+O to expand";
|
|
7
|
+
|
|
8
|
+
export type RendererTheme = {
|
|
9
|
+
fg(style: string, text: string): string;
|
|
10
|
+
bold(text: string): string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function renderToolLabel(theme: RendererTheme, label: string): string {
|
|
14
|
+
const boldFn = typeof theme.bold === "function" ? theme.bold.bind(theme) : (text: string) => text;
|
|
15
|
+
return theme.fg("toolTitle", boldFn(label));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function linkToolPath(styledText: string, rawPath: string, cwd: string): string {
|
|
19
|
+
try {
|
|
20
|
+
if (!getCapabilities().hyperlinks) return styledText;
|
|
21
|
+
const absolutePath = resolveToCwd(rawPath, cwd);
|
|
22
|
+
return hyperlink(styledText, pathToFileURL(absolutePath).href);
|
|
23
|
+
} catch {
|
|
24
|
+
return styledText;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function appendExpandHint(text: string, hidden: boolean): string {
|
|
29
|
+
return hidden ? `${text}${EXPAND_HINT}` : text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function summaryLine(summary: string, options: { hidden?: boolean } = {}): string {
|
|
33
|
+
return appendExpandHint(`${SUMMARY_PREFIX} ${summary}`, !!options.hidden);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isRendererExpanded(options?: { expanded?: boolean }, context?: { expanded?: boolean }): boolean {
|
|
37
|
+
return context?.expanded ?? options?.expanded ?? false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeWidth(width: unknown, fallback = 80): number {
|
|
41
|
+
return typeof width === "number" && Number.isFinite(width) && width > 0 ? Math.floor(width) : fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clampLineToWidth(line: string, width: number | undefined): string {
|
|
45
|
+
if (width === undefined || width === null) return line;
|
|
46
|
+
const normalized = normalizeWidth(width);
|
|
47
|
+
return visibleWidth(line) <= normalized ? line : truncateToWidth(line, normalized);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function clampLinesToWidth(lines: string[], width: number | undefined): string[] {
|
|
51
|
+
if (width === undefined || width === null) return lines;
|
|
52
|
+
return lines.map((line) => clampLineToWidth(line, width));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function wrapLinesToWidth(lines: string[], width: number | undefined): string[] {
|
|
56
|
+
if (width === undefined || width === null) return lines;
|
|
57
|
+
const normalized = normalizeWidth(width);
|
|
58
|
+
return lines.flatMap((line) => {
|
|
59
|
+
if (visibleWidth(line) <= normalized) return [line];
|
|
60
|
+
return wrapTextWithAnsi(line, normalized).map((wrapped) => clampLineToWidth(wrapped, normalized));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface WrapWithHangingIndentOptions {
|
|
65
|
+
/** Optional transform applied to each produced line (e.g. theme tinting). */
|
|
66
|
+
tint?: (text: string) => string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Wrap a single visual row that has a leading prefix (gutter, line number,
|
|
71
|
+
* separator) such that continuation lines are indented to align with the
|
|
72
|
+
* content column. Each produced line is clamped to `width`. If `tint` is
|
|
73
|
+
* provided, it is applied to each output line so theme styling extends across
|
|
74
|
+
* wrapped rows without leaking the prefix into the colored span.
|
|
75
|
+
*/
|
|
76
|
+
export function wrapWithHangingIndent(
|
|
77
|
+
prefix: string,
|
|
78
|
+
content: string,
|
|
79
|
+
width: number | undefined,
|
|
80
|
+
options: WrapWithHangingIndentOptions = {},
|
|
81
|
+
): string[] {
|
|
82
|
+
const tint = options.tint ?? ((text: string) => text);
|
|
83
|
+
if (width === undefined || width === null) return [tint(prefix + content)];
|
|
84
|
+
const normalized = normalizeWidth(width);
|
|
85
|
+
const combined = prefix + content;
|
|
86
|
+
if (visibleWidth(combined) <= normalized) return [tint(combined)];
|
|
87
|
+
const prefixWidth = visibleWidth(prefix);
|
|
88
|
+
const contentWidth = Math.max(1, normalized - prefixWidth);
|
|
89
|
+
const wrapped = wrapTextWithAnsi(content, contentWidth);
|
|
90
|
+
if (wrapped.length === 0) return [tint(clampLineToWidth(prefix, normalized))];
|
|
91
|
+
const indent = " ".repeat(prefixWidth);
|
|
92
|
+
return wrapped.map((line, index) =>
|
|
93
|
+
tint(clampLineToWidth(index === 0 ? prefix + line : indent + line, normalized)),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const HASHLINE_CONTENT_RE = /^(\d+:[0-9a-fA-F]+\|)(.*)$/;
|
|
97
|
+
|
|
98
|
+
export function wrapReadHashlinesForWidth(text: string, width: number | undefined): string {
|
|
99
|
+
if (width === undefined || width === null) return text;
|
|
100
|
+
const normalized = normalizeWidth(width);
|
|
101
|
+
const output: string[] = [];
|
|
102
|
+
for (const line of text.split("\n")) {
|
|
103
|
+
const match = line.match(HASHLINE_CONTENT_RE);
|
|
104
|
+
if (!match) {
|
|
105
|
+
output.push(line);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (visibleWidth(line) <= normalized) {
|
|
109
|
+
output.push(line);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const prefix = match[1]!;
|
|
114
|
+
const content = match[2] ?? "";
|
|
115
|
+
const prefixWidth = visibleWidth(prefix);
|
|
116
|
+
const contentWidth = Math.max(1, normalized - prefixWidth);
|
|
117
|
+
const wrappedContent = wrapTextWithAnsi(content, contentWidth).map((wrapped) => clampLineToWidth(wrapped, contentWidth));
|
|
118
|
+
if (wrappedContent.length === 0) {
|
|
119
|
+
output.push(clampLineToWidth(prefix, normalized));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
output.push(clampLineToWidth(prefix + wrappedContent[0], normalized));
|
|
123
|
+
const indent = " ".repeat(prefixWidth);
|
|
124
|
+
for (const continuation of wrappedContent.slice(1)) {
|
|
125
|
+
output.push(clampLineToWidth(indent + continuation, normalized));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return output.join("\n");
|
|
129
|
+
}
|