sift-light 1.0.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 +0 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +661 -0
- package/README.md +177 -0
- package/README.zh-CN.md +177 -0
- package/SECURITY.md +25 -0
- package/package.json +142 -0
- package/plugins/sift-light/.claude-plugin/plugin.json +10 -0
- package/plugins/sift-light/.codex-plugin/plugin.json +20 -0
- package/plugins/sift-light/.mcp.json +24 -0
- package/plugins/sift-light/LICENSE +661 -0
- package/plugins/sift-light/concept-worker.mjs +567 -0
- package/plugins/sift-light/hooks/THIRD_PARTY_NOTICES.txt +70 -0
- package/plugins/sift-light/hooks/hooks.json +16 -0
- package/plugins/sift-light/hooks/search-policy.mjs +4113 -0
- package/plugins/sift-light/hooks/tree-sitter-bash.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter-powershell.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter.wasm +0 -0
- package/plugins/sift-light/kimi.plugin.json +36 -0
- package/plugins/sift-light/mcp-server.mjs +1 -0
- package/plugins/sift-light/omp-extension.mjs +20914 -0
- package/plugins/sift-light/package.json +11 -0
- package/plugins/sift-light/syntax-worker.mjs +280 -0
- package/plugins/sift-light/syntax-worker.toml +2 -0
- package/src/analysis-evidence.ts +43 -0
- package/src/analysis-limits.ts +27 -0
- package/src/analysis-store.ts +559 -0
- package/src/analysis-term-pages.ts +73 -0
- package/src/analysis-types.ts +162 -0
- package/src/audit-search.ts +391 -0
- package/src/capped-lines.ts +112 -0
- package/src/concept-embedding-cache.ts +186 -0
- package/src/concept-inference.ts +218 -0
- package/src/concept-model.ts +96 -0
- package/src/concept-search.ts +577 -0
- package/src/concept-setup.ts +75 -0
- package/src/concept-source-generation.ts +268 -0
- package/src/concept-worker.mjs +567 -0
- package/src/concept-worker.ts +210 -0
- package/src/config-reader.ts +283 -0
- package/src/config.ts +37 -0
- package/src/context-budget.ts +26 -0
- package/src/discovery-errors.ts +2 -0
- package/src/errors.ts +95 -0
- package/src/evidence-candidates.ts +344 -0
- package/src/evidence-ranking.ts +48 -0
- package/src/evidence-service.ts +1047 -0
- package/src/evidence-validation.ts +466 -0
- package/src/evidence-validity.ts +33 -0
- package/src/excerpt.ts +53 -0
- package/src/file-discovery.ts +150 -0
- package/src/file-metadata-filter.ts +46 -0
- package/src/format.ts +407 -0
- package/src/git-diff.ts +195 -0
- package/src/git-process.ts +189 -0
- package/src/git-repository.ts +367 -0
- package/src/git-source.ts +385 -0
- package/src/historical-paths.ts +205 -0
- package/src/hybrid-search.ts +340 -0
- package/src/import-model.ts +611 -0
- package/src/import-navigation.ts +100 -0
- package/src/import-resolution.ts +240 -0
- package/src/index.ts +97 -0
- package/src/inspect-batch.ts +265 -0
- package/src/inspect.ts +271 -0
- package/src/language-capabilities.ts +157 -0
- package/src/language-capability-definitions.ts +204 -0
- package/src/literal-search.ts +25 -0
- package/src/mcp-cli.ts +21 -0
- package/src/mcp-model-output.ts +120 -0
- package/src/mcp-output.ts +9 -0
- package/src/mcp-semantic-judge.ts +39 -0
- package/src/mcp-server.mjs +14064 -0
- package/src/mcp-server.ts +157 -0
- package/src/mcp-stdio.ts +114 -0
- package/src/mcp.ts +631 -0
- package/src/model-error.ts +120 -0
- package/src/multi-term-search.ts +122 -0
- package/src/omp-index.ts +277 -0
- package/src/omp-marketplace-entry.ts +7 -0
- package/src/operation-lifecycle.ts +413 -0
- package/src/operation-output.ts +100 -0
- package/src/owned-parallel.ts +22 -0
- package/src/owned-process.ts +102 -0
- package/src/owned-task-queue.ts +38 -0
- package/src/package-version.ts +3 -0
- package/src/path-policy.ts +203 -0
- package/src/pi-search-policy.ts +33 -0
- package/src/prompt-guidelines.ts +54 -0
- package/src/python-outline.ts +231 -0
- package/src/record-value.ts +3 -0
- package/src/redaction.ts +95 -0
- package/src/request-contract-catalog.ts +215 -0
- package/src/request-contract-error.ts +96 -0
- package/src/request-contract.ts +481 -0
- package/src/request.ts +132 -0
- package/src/result-statistics.ts +178 -0
- package/src/rg.ts +574 -0
- package/src/ripgrep-diagnostics.ts +122 -0
- package/src/ripgrep-executable.ts +43 -0
- package/src/runtime.ts +53 -0
- package/src/scan-revisions.ts +148 -0
- package/src/script-runtime.ts +11 -0
- package/src/search-policy-commands.ts +170 -0
- package/src/search-policy-hook.ts +61 -0
- package/src/search-policy-recovery.ts +388 -0
- package/src/search-policy-shell.ts +280 -0
- package/src/search-policy.ts +102 -0
- package/src/search-retention.ts +77 -0
- package/src/semantic-judge-batches.ts +135 -0
- package/src/semantic-judge.ts +519 -0
- package/src/service.ts +945 -0
- package/src/session-summary.ts +100 -0
- package/src/snapshot-store.ts +203 -0
- package/src/source-access.ts +288 -0
- package/src/source-continuations.ts +168 -0
- package/src/source-document.ts +277 -0
- package/src/source-inspection.ts +731 -0
- package/src/source-pages.ts +112 -0
- package/src/source.ts +286 -0
- package/src/structural-search.ts +125 -0
- package/src/structure.ts +259 -0
- package/src/summary-previews.ts +96 -0
- package/src/summary.ts +79 -0
- package/src/syntax-facts.ts +464 -0
- package/src/syntax-fields.ts +161 -0
- package/src/syntax-search.ts +361 -0
- package/src/syntax-tree.ts +30 -0
- package/src/syntax-types.ts +72 -0
- package/src/syntax-worker.mjs +280 -0
- package/src/syntax-worker.toml +2 -0
- package/src/syntax-worker.ts +143 -0
- package/src/syntax.ts +259 -0
- package/src/test-navigation-facts.ts +261 -0
- package/src/test-navigation.ts +543 -0
- package/src/tool-schema.ts +321 -0
- package/src/tui/dashboard.ts +107 -0
- package/src/tui/layout.ts +291 -0
- package/src/tui/palette.ts +73 -0
- package/src/tui/presentation.ts +240 -0
- package/src/tui/renderers.ts +106 -0
- package/src/types.ts +368 -0
- package/src/validation-output.ts +148 -0
- package/src/validation-types.ts +48 -0
- package/src/workspace-files.ts +142 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { SiftLightLocale } from "../config.js";
|
|
3
|
+
import type { SiftLightInput } from "../service.js";
|
|
4
|
+
import type { Dashboard } from "./dashboard.js";
|
|
5
|
+
|
|
6
|
+
import { candy, type SiftLightTheme } from "./palette.js";
|
|
7
|
+
export type { SiftLightTheme } from "./palette.js";
|
|
8
|
+
const DIGITS = [
|
|
9
|
+
["█▀█", "█ █", "█▄█"],
|
|
10
|
+
["▄█ ", " █ ", "▄█▄"],
|
|
11
|
+
["▀▀█", "▄▀▀", "█▄▄"],
|
|
12
|
+
["▀▀█", " ▀█", "▄▄█"],
|
|
13
|
+
["█ █", "▀▀█", " █"],
|
|
14
|
+
["█▀▀", "▀▀█", "▄▄█"],
|
|
15
|
+
["█▀▀", "█▀█", "█▄█"],
|
|
16
|
+
["▀▀█", " █", " █"],
|
|
17
|
+
["█▀█", "█▀█", "█▄█"],
|
|
18
|
+
["█▀█", "▀▀█", "▄▄█"],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function safeLabel(value: string): string {
|
|
22
|
+
return value.replace(/[\p{Cc}\p{Cf}]/gu, "�");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function fit(lines: string[], width: number): string[] {
|
|
26
|
+
if (width <= 0) return [];
|
|
27
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Left margin that keeps the dashboard off the terminal edge. */
|
|
31
|
+
const GUTTER = 2;
|
|
32
|
+
/** The path column stops growing here so counts and bars stay near the left. */
|
|
33
|
+
const PATH_COLUMN_MAX = 56;
|
|
34
|
+
const PATH_COLUMN_MIN = 16;
|
|
35
|
+
|
|
36
|
+
function prefixWithin(text: string, width: number): string {
|
|
37
|
+
let out = "";
|
|
38
|
+
let used = 0;
|
|
39
|
+
for (const character of text) {
|
|
40
|
+
const size = visibleWidth(character);
|
|
41
|
+
if (used + size > width) break;
|
|
42
|
+
out += character;
|
|
43
|
+
used += size;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function suffixWithin(text: string, width: number): string {
|
|
49
|
+
const characters = Array.from(text);
|
|
50
|
+
let out = "";
|
|
51
|
+
let used = 0;
|
|
52
|
+
for (let index = characters.length - 1; index >= 0; index -= 1) {
|
|
53
|
+
const character = characters[index] ?? "";
|
|
54
|
+
const size = visibleWidth(character);
|
|
55
|
+
if (used + size > width) break;
|
|
56
|
+
out = character + out;
|
|
57
|
+
used += size;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Drop the middle of an over-long path instead of its tail. The tail carries the
|
|
64
|
+
* basename, which is what distinguishes sibling files such as `README.md` and
|
|
65
|
+
* `README.zh.md`; an end truncation would render those rows identical.
|
|
66
|
+
*/
|
|
67
|
+
export function truncatePath(text: string, width: number): string {
|
|
68
|
+
if (width <= 0) return "";
|
|
69
|
+
if (visibleWidth(text) <= width) return text;
|
|
70
|
+
if (width <= 2) return prefixWithin(text, width);
|
|
71
|
+
const budget = width - 1;
|
|
72
|
+
const head = Math.ceil(budget * 0.4);
|
|
73
|
+
return `${prefixWithin(text, head)}…${suffixWithin(text, budget - head)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function indent(lines: string[], gutter: number): string[] {
|
|
77
|
+
if (gutter <= 0) return lines;
|
|
78
|
+
const pad = " ".repeat(gutter);
|
|
79
|
+
return lines.map((line) => (line.length === 0 ? line : pad + line));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function renderDashboard(
|
|
83
|
+
view: Dashboard,
|
|
84
|
+
locale: SiftLightLocale,
|
|
85
|
+
theme: SiftLightTheme,
|
|
86
|
+
width: number,
|
|
87
|
+
expanded: boolean,
|
|
88
|
+
): string[] {
|
|
89
|
+
if (width <= 0) return [];
|
|
90
|
+
const gutter = width > GUTTER ? GUTTER : 0;
|
|
91
|
+
const content = width - gutter;
|
|
92
|
+
const zh = locale === "zh-CN";
|
|
93
|
+
const units = zh
|
|
94
|
+
? { matches: "处匹配", items: "项结果", files: "个文件", locations: "个位置" }
|
|
95
|
+
: { matches: "matches", items: "items", files: "files", locations: "locations" };
|
|
96
|
+
const state = view.partial
|
|
97
|
+
? zh
|
|
98
|
+
? "部分结果"
|
|
99
|
+
: "Partial results"
|
|
100
|
+
: zh
|
|
101
|
+
? "搜索完成"
|
|
102
|
+
: "Search complete";
|
|
103
|
+
const lines = [
|
|
104
|
+
view.partial ? theme.fg("warning", `! ${state}`) : candy(theme, 3, `✓ ${state}`),
|
|
105
|
+
"",
|
|
106
|
+
];
|
|
107
|
+
const number = String(view.total);
|
|
108
|
+
const digitWidth = number.length * 4;
|
|
109
|
+
const metrics = [
|
|
110
|
+
`${number} ${units[view.unit]}`,
|
|
111
|
+
`${String(view.files)} ${zh ? "个文件" : "files"}`,
|
|
112
|
+
];
|
|
113
|
+
const limit = expanded ? 30 : content >= 60 ? 6 : 3;
|
|
114
|
+
const rows = view.rows.slice(0, limit);
|
|
115
|
+
const max = Math.max(...view.rows.map((row) => row.matches), 1);
|
|
116
|
+
const countWidth = Math.max(...rows.map((row) => String(row.matches).length), 1);
|
|
117
|
+
const barWidth = content >= 44 ? Math.min(18, Math.floor(content / 4)) : 0;
|
|
118
|
+
// The path column is capped instead of absorbing every spare column: filling
|
|
119
|
+
// the terminal pushed each count and bar to the far right edge, where they
|
|
120
|
+
// read as a detached column rather than as this row's statistics.
|
|
121
|
+
const reserved = countWidth + barWidth + (barWidth ? 6 : 4);
|
|
122
|
+
const pathCap = Math.max(PATH_COLUMN_MIN, Math.min(PATH_COLUMN_MAX, Math.floor(content / 2)));
|
|
123
|
+
const pathWidth = Math.max(1, Math.min(content - reserved, pathCap));
|
|
124
|
+
const tableWidth = 2 + pathWidth + 2 + countWidth + 2 + barWidth;
|
|
125
|
+
const strip = (cells: number): string => {
|
|
126
|
+
const shown = rows.reduce((sum, row) => sum + row.matches, 0);
|
|
127
|
+
const denominator = Math.max(view.pageOnly ? shown : view.total, shown, 1);
|
|
128
|
+
let cumulative = 0;
|
|
129
|
+
let occupied = 0;
|
|
130
|
+
const segments = rows.map((row, index) => {
|
|
131
|
+
cumulative += row.matches;
|
|
132
|
+
const end = Math.round((cumulative / denominator) * cells);
|
|
133
|
+
const segment = candy(theme, index, "━".repeat(end - occupied));
|
|
134
|
+
occupied = end;
|
|
135
|
+
return segment;
|
|
136
|
+
});
|
|
137
|
+
return segments.join("") + theme.fg("borderMuted", "─".repeat(cells - occupied));
|
|
138
|
+
};
|
|
139
|
+
const title = view.pageOnly
|
|
140
|
+
? zh
|
|
141
|
+
? "本页文件分布"
|
|
142
|
+
: "Files on this page"
|
|
143
|
+
: zh
|
|
144
|
+
? "文件分布"
|
|
145
|
+
: "File distribution";
|
|
146
|
+
const numerals: string[] = [];
|
|
147
|
+
if (content >= digitWidth + 22 && content >= 44) {
|
|
148
|
+
for (let index = 0; index < 3; index += 1) {
|
|
149
|
+
const glyph = Array.from(number, (digit, position) =>
|
|
150
|
+
candy(theme, position, DIGITS[Number(digit)]?.[index] ?? " "),
|
|
151
|
+
).join(" ");
|
|
152
|
+
numerals.push(`${glyph} ${index === 0 ? metrics[0] : index === 1 ? metrics[1] : ""}`);
|
|
153
|
+
}
|
|
154
|
+
} else numerals.push(theme.bold(metrics.join(" · ")));
|
|
155
|
+
// Block digits already make the overview three rows tall. The distribution
|
|
156
|
+
// strip is the only element wide enough to fill the empty corner beside them,
|
|
157
|
+
// so it moves up here and both columns end on the table's right edge.
|
|
158
|
+
const overview = ((): string[] | undefined => {
|
|
159
|
+
if (rows.length === 0 || view.inspection || barWidth === 0) return undefined;
|
|
160
|
+
const leftWidth = Math.max(...numerals.map((line) => visibleWidth(line)));
|
|
161
|
+
const rightWidth = Math.min(tableWidth, content) - leftWidth - 3;
|
|
162
|
+
if (rightWidth < 24) return undefined;
|
|
163
|
+
const right = [theme.fg("text", title), strip(rightWidth), ""];
|
|
164
|
+
return numerals.map((line, index) => {
|
|
165
|
+
const pad = " ".repeat(Math.max(0, leftWidth - visibleWidth(line)));
|
|
166
|
+
return `${line}${pad} ${right[index] ?? ""}`.trimEnd();
|
|
167
|
+
});
|
|
168
|
+
})();
|
|
169
|
+
if (overview) lines.push(...overview, "");
|
|
170
|
+
else {
|
|
171
|
+
lines.push(...numerals);
|
|
172
|
+
if (rows.length > 0) {
|
|
173
|
+
lines.push("", theme.fg("text", title));
|
|
174
|
+
if (content >= 44 && !view.inspection) lines.push(strip(Math.min(content, tableWidth)), "");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (view.partial) {
|
|
178
|
+
lines.push(
|
|
179
|
+
theme.fg(
|
|
180
|
+
"warning",
|
|
181
|
+
zh ? "结果不完整,请缩小范围重试" : "Incomplete results; narrow the search",
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
if (view.unit === "matches")
|
|
185
|
+
lines.push(
|
|
186
|
+
theme.fg("warning", `${zh ? "已保留" : "Retained"} ${String(view.stored)}/${number}`),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (rows.length) {
|
|
190
|
+
for (const [index, row] of rows.entries()) {
|
|
191
|
+
const name = truncatePath(safeLabel(row.path), pathWidth);
|
|
192
|
+
const label = name + " ".repeat(Math.max(0, pathWidth - visibleWidth(name)));
|
|
193
|
+
const count = String(row.matches).padStart(countWidth);
|
|
194
|
+
const bar = barWidth
|
|
195
|
+
? ` ${"█".repeat(Math.max(row.matches > 0 ? 1 : 0, Math.round((row.matches / max) * barWidth)))}`
|
|
196
|
+
: "";
|
|
197
|
+
lines.push(
|
|
198
|
+
`${candy(theme, index, "▎")} ${label} ${theme.bold(count)}${candy(theme, index, bar)}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
// Both notes describe the same list from different angles; stacking them as
|
|
202
|
+
// separate short sentences read as loose leftover text under the table.
|
|
203
|
+
const hidden = view.rows.length - rows.length;
|
|
204
|
+
const parts: string[] = [];
|
|
205
|
+
if (hidden > 0)
|
|
206
|
+
parts.push(
|
|
207
|
+
`${zh ? "另有" : "Another"} ${String(hidden)} ${zh ? "个文件未展示" : "files not shown"}`,
|
|
208
|
+
);
|
|
209
|
+
if (view.files > view.rows.length)
|
|
210
|
+
parts.push(
|
|
211
|
+
zh
|
|
212
|
+
? `已展示 ${String(view.rows.length)}/${String(view.files)} 个文件`
|
|
213
|
+
: `Showing ${String(view.rows.length)}/${String(view.files)} files`,
|
|
214
|
+
);
|
|
215
|
+
if (parts.length > 0) {
|
|
216
|
+
const hint = hidden > 0 ? (zh ? "展开查看更多" : "expand to see more") : "";
|
|
217
|
+
const full = [...parts, ...(hint ? [hint] : [])].join(" · ");
|
|
218
|
+
const short = parts.join(" · ");
|
|
219
|
+
lines.push("");
|
|
220
|
+
if (visibleWidth(full) <= content) lines.push(theme.fg("text", full));
|
|
221
|
+
else if (visibleWidth(short) <= content) lines.push(theme.fg("text", short));
|
|
222
|
+
// Only a terminal this narrow still splits the notes; a truncated single
|
|
223
|
+
// line would cut the retained/total counts mid-number.
|
|
224
|
+
else lines.push(...parts.map((part) => theme.fg("text", part)));
|
|
225
|
+
}
|
|
226
|
+
} else if (view.total === 0)
|
|
227
|
+
lines.push(theme.fg("text", zh ? "没有找到匹配结果" : "No results found"));
|
|
228
|
+
else lines.push(theme.fg("text", zh ? "本次未提供文件分布" : "File distribution unavailable"));
|
|
229
|
+
if (view.unavailable)
|
|
230
|
+
lines.push(
|
|
231
|
+
theme.fg(
|
|
232
|
+
"warning",
|
|
233
|
+
`${String(view.unavailable)} ${zh ? "个位置未能完成读取" : "locations could not be read"}`,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
lines.push(
|
|
237
|
+
"",
|
|
238
|
+
theme.fg(
|
|
239
|
+
"text",
|
|
240
|
+
view.more
|
|
241
|
+
? zh
|
|
242
|
+
? "还有结果可继续查看"
|
|
243
|
+
: "More results available"
|
|
244
|
+
: view.partial
|
|
245
|
+
? zh
|
|
246
|
+
? "本次搜索未覆盖全部结果"
|
|
247
|
+
: "Search coverage is incomplete"
|
|
248
|
+
: zh
|
|
249
|
+
? "本次返回已结束"
|
|
250
|
+
: "End of this result",
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
return fit(indent(lines, gutter), width);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function renderSiftLightCallLines(
|
|
257
|
+
input: SiftLightInput,
|
|
258
|
+
locale: SiftLightLocale,
|
|
259
|
+
theme: SiftLightTheme,
|
|
260
|
+
width: number,
|
|
261
|
+
): string[] {
|
|
262
|
+
const zh = locale === "zh-CN";
|
|
263
|
+
const action =
|
|
264
|
+
input.mode === "inspect"
|
|
265
|
+
? zh
|
|
266
|
+
? "查看文件"
|
|
267
|
+
: "Inspect files"
|
|
268
|
+
: input.cursor || input.sourceCursor || input.mode === "await"
|
|
269
|
+
? zh
|
|
270
|
+
? "继续查看"
|
|
271
|
+
: "Continue search"
|
|
272
|
+
: input.mode === "cancel"
|
|
273
|
+
? zh
|
|
274
|
+
? "停止搜索"
|
|
275
|
+
: "Stop search"
|
|
276
|
+
: zh
|
|
277
|
+
? "搜索"
|
|
278
|
+
: "Search";
|
|
279
|
+
if (width <= 0) return [];
|
|
280
|
+
const gutter = width > GUTTER ? GUTTER : 0;
|
|
281
|
+
return fit(
|
|
282
|
+
indent(
|
|
283
|
+
[
|
|
284
|
+
`${theme.fg("accent", theme.bold("sift-light"))} ${action}`,
|
|
285
|
+
...(input.path ? [theme.fg("text", safeLabel(input.path))] : []),
|
|
286
|
+
],
|
|
287
|
+
gutter,
|
|
288
|
+
),
|
|
289
|
+
width,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Theme as PiTheme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type SiftLightTheme = Pick<PiTheme, "bold" | "fg"> &
|
|
4
|
+
Partial<Pick<PiTheme, "getBgAnsi" | "getFgAnsi" | "getColorMode">>;
|
|
5
|
+
const DARK = [
|
|
6
|
+
[255, 95, 175],
|
|
7
|
+
[255, 135, 0],
|
|
8
|
+
[255, 215, 0],
|
|
9
|
+
[95, 255, 95],
|
|
10
|
+
[0, 215, 255],
|
|
11
|
+
[175, 135, 255],
|
|
12
|
+
] as const;
|
|
13
|
+
const LIGHT = [
|
|
14
|
+
[215, 0, 95],
|
|
15
|
+
[215, 95, 0],
|
|
16
|
+
[175, 135, 0],
|
|
17
|
+
[0, 135, 0],
|
|
18
|
+
[0, 135, 175],
|
|
19
|
+
[135, 0, 255],
|
|
20
|
+
] as const;
|
|
21
|
+
const TOKENS = [
|
|
22
|
+
"syntaxKeyword",
|
|
23
|
+
"syntaxType",
|
|
24
|
+
"accent",
|
|
25
|
+
"syntaxString",
|
|
26
|
+
"syntaxNumber",
|
|
27
|
+
"syntaxFunction",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
function brightness(ansi: string): number | undefined {
|
|
31
|
+
if (!ansi.startsWith("\u001b[")) return undefined;
|
|
32
|
+
const rgb = /^\[(?:38|48);2;(\d+);(\d+);(\d+)m/u.exec(ansi.slice(1));
|
|
33
|
+
if (rgb) return (Number(rgb[1]) * 299 + Number(rgb[2]) * 587 + Number(rgb[3]) * 114) / 255000;
|
|
34
|
+
const indexed = /^\[(?:38|48);5;(\d+)m/u.exec(ansi.slice(1));
|
|
35
|
+
if (!indexed) return undefined;
|
|
36
|
+
const n = Number(indexed[1]);
|
|
37
|
+
if (n >= 232) return (8 + (n - 232) * 10) / 255;
|
|
38
|
+
if (n < 16) return undefined; // ANSI base colors are user-defined.
|
|
39
|
+
const levels = [0, 95, 135, 175, 215, 255];
|
|
40
|
+
const index = n - 16;
|
|
41
|
+
return (
|
|
42
|
+
((levels[Math.floor(index / 36)] ?? 0) * 299 +
|
|
43
|
+
(levels[Math.floor(index / 6) % 6] ?? 0) * 587 +
|
|
44
|
+
(levels[index % 6] ?? 0) * 114) /
|
|
45
|
+
255000
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read host styling on every render; no independent theme state or terminal queries. */
|
|
50
|
+
export function candy(theme: SiftLightTheme, index: number, text: string): string {
|
|
51
|
+
const background = theme.getBgAnsi ? brightness(theme.getBgAnsi("toolSuccessBg")) : undefined;
|
|
52
|
+
const foreground = theme.getFgAnsi ? brightness(theme.getFgAnsi("text")) : undefined;
|
|
53
|
+
const light =
|
|
54
|
+
background === undefined
|
|
55
|
+
? foreground === undefined
|
|
56
|
+
? undefined
|
|
57
|
+
: foreground < 0.5
|
|
58
|
+
: background > 0.5;
|
|
59
|
+
const slot = index % DARK.length;
|
|
60
|
+
if (light === undefined) return theme.fg(TOKENS[slot] ?? "accent", text);
|
|
61
|
+
const [r, g, b] = (light ? LIGHT : DARK)[slot] ?? DARK[0];
|
|
62
|
+
if (theme.getColorMode?.() === "truecolor")
|
|
63
|
+
return `\u001b[38;2;${String(r)};${String(g)};${String(b)}m${text}\u001b[39m`;
|
|
64
|
+
const levels = [0, 95, 135, 175, 215, 255];
|
|
65
|
+
const nearest = (value: number): number =>
|
|
66
|
+
levels.reduce(
|
|
67
|
+
(best, level, i) =>
|
|
68
|
+
Math.abs(level - value) < Math.abs((levels[best] ?? 0) - value) ? i : best,
|
|
69
|
+
0,
|
|
70
|
+
);
|
|
71
|
+
const color = 16 + nearest(r) * 36 + nearest(g) * 6 + nearest(b);
|
|
72
|
+
return `\u001b[38;5;${String(color)}m${text}\u001b[39m`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InspectBatchItemDetails,
|
|
3
|
+
SearchMode,
|
|
4
|
+
SiftLightDetails,
|
|
5
|
+
StructureStatus,
|
|
6
|
+
} from "../types.js";
|
|
7
|
+
|
|
8
|
+
export interface SummaryRow {
|
|
9
|
+
path: string;
|
|
10
|
+
matches: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PresentationBase {
|
|
14
|
+
details: SiftLightDetails;
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface EmptyPresentation extends PresentationBase {
|
|
19
|
+
kind: "empty";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SummaryPresentation extends PresentationBase {
|
|
23
|
+
kind: "summary";
|
|
24
|
+
rows: SummaryRow[];
|
|
25
|
+
previews: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InspectBatchPresentation extends PresentationBase {
|
|
29
|
+
kind: "inspect-batch";
|
|
30
|
+
items: InspectBatchItemDetails[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MatchesPresentation extends PresentationBase {
|
|
34
|
+
kind: "matches";
|
|
35
|
+
bodyLines: string[];
|
|
36
|
+
firstMatch?: number;
|
|
37
|
+
lastMatch?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface InspectPresentation extends PresentationBase {
|
|
41
|
+
kind: "inspect";
|
|
42
|
+
target: string;
|
|
43
|
+
descriptor?: string;
|
|
44
|
+
sourceLines: string[];
|
|
45
|
+
status: StructureStatus;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type SiftLightPresentation =
|
|
49
|
+
| EmptyPresentation
|
|
50
|
+
| SummaryPresentation
|
|
51
|
+
| MatchesPresentation
|
|
52
|
+
| InspectPresentation
|
|
53
|
+
| InspectBatchPresentation;
|
|
54
|
+
|
|
55
|
+
const STRUCTURE_STATUSES = new Set<StructureStatus>([
|
|
56
|
+
"available",
|
|
57
|
+
"no-symbol",
|
|
58
|
+
"provider-unavailable",
|
|
59
|
+
"source-unavailable",
|
|
60
|
+
"parse-error",
|
|
61
|
+
"file-too-large",
|
|
62
|
+
"source-changed",
|
|
63
|
+
]);
|
|
64
|
+
const SEARCH_MODES = new Set<SearchMode>(["auto", "summary", "matches", "inspect"]);
|
|
65
|
+
|
|
66
|
+
function isNonNegativeSafeInteger(value: number): boolean {
|
|
67
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasRecognizableDetails(
|
|
71
|
+
details: SiftLightDetails | undefined,
|
|
72
|
+
): details is SiftLightDetails {
|
|
73
|
+
if (!details || details.version !== 1 || details.analysis !== undefined) return false;
|
|
74
|
+
if (!SEARCH_MODES.has(details.mode)) return false;
|
|
75
|
+
if (details.status !== "complete" && details.status !== "partial") return false;
|
|
76
|
+
const counts = [
|
|
77
|
+
details.totalMatches,
|
|
78
|
+
details.storedMatches,
|
|
79
|
+
details.totalFiles,
|
|
80
|
+
details.returnedMatches,
|
|
81
|
+
];
|
|
82
|
+
if (counts.some((value) => !isNonNegativeSafeInteger(value))) return false;
|
|
83
|
+
if (details.storedMatches > details.totalMatches) return false;
|
|
84
|
+
if (details.snapshotComplete !== (details.status === "complete")) return false;
|
|
85
|
+
return !details.snapshotComplete || details.storedMatches === details.totalMatches;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseSummaryRows(text: string, expectedRows: number): SummaryRow[] | undefined {
|
|
89
|
+
if (!isNonNegativeSafeInteger(expectedRows)) return undefined;
|
|
90
|
+
if (expectedRows === 0) return undefined;
|
|
91
|
+
|
|
92
|
+
const lines = text.split("\n");
|
|
93
|
+
const rangeIndex = lines.findIndex((line) =>
|
|
94
|
+
/^Files \d+-\d+ of \d+, ordered by match count\.$/.test(line),
|
|
95
|
+
);
|
|
96
|
+
if (rangeIndex < 0 || lines[rangeIndex + 1] !== "") return undefined;
|
|
97
|
+
|
|
98
|
+
const rows: SummaryRow[] = [];
|
|
99
|
+
for (const line of lines.slice(rangeIndex + 2, rangeIndex + 2 + expectedRows)) {
|
|
100
|
+
const match = /^(\S(?:.*\S)?) {2,}(\d+)$/.exec(line);
|
|
101
|
+
if (!match) return undefined;
|
|
102
|
+
const path = match[1];
|
|
103
|
+
const countText = match[2];
|
|
104
|
+
if (!path || !countText) return undefined;
|
|
105
|
+
const count = Number(countText);
|
|
106
|
+
if (!Number.isSafeInteger(count) || count < 1) return undefined;
|
|
107
|
+
rows.push({ path, matches: count });
|
|
108
|
+
}
|
|
109
|
+
return rows.length === expectedRows ? rows : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function splitMatchBody(text: string): string[] | undefined {
|
|
113
|
+
const markers = [
|
|
114
|
+
"\n\n[Match columns ",
|
|
115
|
+
"\n\n[Context omitted ",
|
|
116
|
+
"\n\n[Context unavailable ",
|
|
117
|
+
"\n\n[Matches ",
|
|
118
|
+
];
|
|
119
|
+
let bodyEnd = text.length;
|
|
120
|
+
for (const marker of markers) {
|
|
121
|
+
const index = text.indexOf(marker);
|
|
122
|
+
if (index >= 0) bodyEnd = Math.min(bodyEnd, index);
|
|
123
|
+
}
|
|
124
|
+
const body = text.slice(0, bodyEnd);
|
|
125
|
+
if (body.length === 0 || !text.includes("\n\n[Matches ")) return undefined;
|
|
126
|
+
return body.split("\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseMatchRange(text: string): Pick<MatchesPresentation, "firstMatch" | "lastMatch"> {
|
|
130
|
+
const match = /\[Matches (\d+)-(\d+) of \d+/.exec(text);
|
|
131
|
+
if (!match) return {};
|
|
132
|
+
const firstMatch = Number(match[1]);
|
|
133
|
+
const lastMatch = Number(match[2]);
|
|
134
|
+
if (
|
|
135
|
+
!Number.isSafeInteger(firstMatch) ||
|
|
136
|
+
!Number.isSafeInteger(lastMatch) ||
|
|
137
|
+
firstMatch < 1 ||
|
|
138
|
+
lastMatch < firstMatch
|
|
139
|
+
) {
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
return { firstMatch, lastMatch };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseInspect(text: string, details: SiftLightDetails): InspectPresentation | undefined {
|
|
146
|
+
const structure = details.structure;
|
|
147
|
+
if (!structure || !STRUCTURE_STATUSES.has(structure.status)) return undefined;
|
|
148
|
+
|
|
149
|
+
const lines = text.split("\n");
|
|
150
|
+
const target = lines[0];
|
|
151
|
+
if (!target) return undefined;
|
|
152
|
+
if (
|
|
153
|
+
structure.status === "source-changed" ||
|
|
154
|
+
structure.status === "file-too-large" ||
|
|
155
|
+
structure.status === "source-unavailable"
|
|
156
|
+
) {
|
|
157
|
+
return {
|
|
158
|
+
kind: "inspect",
|
|
159
|
+
details,
|
|
160
|
+
text,
|
|
161
|
+
target,
|
|
162
|
+
sourceLines: [],
|
|
163
|
+
status: structure.status,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const descriptor = lines[1] || undefined;
|
|
168
|
+
const structureMarker = text.lastIndexOf("\n\n[structure: ");
|
|
169
|
+
if (!descriptor || structureMarker < 0) return undefined;
|
|
170
|
+
const sourceStart = text.indexOf("\n\n", target.length + 1);
|
|
171
|
+
if (sourceStart < 0 || sourceStart >= structureMarker) return undefined;
|
|
172
|
+
const sourceLines = text.slice(sourceStart + 2, structureMarker).split("\n");
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
kind: "inspect",
|
|
176
|
+
details,
|
|
177
|
+
text,
|
|
178
|
+
target,
|
|
179
|
+
descriptor,
|
|
180
|
+
sourceLines,
|
|
181
|
+
status: structure.status,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function recognizeSiftLightResult(
|
|
186
|
+
text: string,
|
|
187
|
+
details: SiftLightDetails | undefined,
|
|
188
|
+
): SiftLightPresentation | undefined {
|
|
189
|
+
if (!hasRecognizableDetails(details)) return undefined;
|
|
190
|
+
|
|
191
|
+
if (details.mode === "inspect") {
|
|
192
|
+
if (details.inspections) {
|
|
193
|
+
if (
|
|
194
|
+
details.inspections.length === 0 ||
|
|
195
|
+
details.inspections.some(
|
|
196
|
+
(item) =>
|
|
197
|
+
!Number.isSafeInteger(item.inputIndex) ||
|
|
198
|
+
item.inputIndex < 1 ||
|
|
199
|
+
!["returned", "deferred", "error"].includes(item.status),
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
return undefined;
|
|
203
|
+
return { kind: "inspect-batch", details, text, items: details.inspections };
|
|
204
|
+
}
|
|
205
|
+
return parseInspect(text, details);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (details.totalMatches === 0) {
|
|
209
|
+
return { kind: "empty", details, text };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (details.summaryFilesShown !== undefined) {
|
|
213
|
+
const rows = parseSummaryRows(text, details.summaryFilesShown);
|
|
214
|
+
if (!rows) return undefined;
|
|
215
|
+
const lines = text.split("\n");
|
|
216
|
+
const sampleHeading = lines.findIndex((line) =>
|
|
217
|
+
line.startsWith("Samples: first retained match"),
|
|
218
|
+
);
|
|
219
|
+
const sampleCount = details.summaryPreviewsShown ?? 0;
|
|
220
|
+
const previews =
|
|
221
|
+
sampleHeading >= 0 && isNonNegativeSafeInteger(sampleCount)
|
|
222
|
+
? lines.slice(sampleHeading + 1, sampleHeading + 1 + sampleCount)
|
|
223
|
+
: [];
|
|
224
|
+
return { kind: "summary", details, text, rows, previews };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (details.returnedMatches > 0) {
|
|
228
|
+
const bodyLines = splitMatchBody(text);
|
|
229
|
+
if (!bodyLines) return undefined;
|
|
230
|
+
return {
|
|
231
|
+
kind: "matches",
|
|
232
|
+
details,
|
|
233
|
+
text,
|
|
234
|
+
bodyLines,
|
|
235
|
+
...parseMatchRange(text),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|